diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8d07dbbc38d..a5074a77d19 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -228,6 +228,27 @@ jobs: - run: npm ci - run: npm run lint-translations + build-generated-docs: + needs: build + name: Build generated docs + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Use Node.js ${{ env.NODE_VERSION }} + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + - run: npm ci + - name: Build Documentation + run: npm run build-documentation + - name: Cache generated docs + if: github.ref == 'refs/heads/master' + uses: actions/cache/save@v4 + with: + key: cht-datasource-docs + path: ./shared-libs/cht-datasource/docs + tests: needs: build name: ${{ matrix.cmd }}-${{ matrix.suite || '' }}${{ matrix.chrome-version == '107' && '-minimum-browser' || '' }} @@ -353,7 +374,7 @@ jobs: if: ${{ failure() }} publish: - needs: [tests, config-tests, test-cht-form] + needs: [tests, config-tests, test-cht-form, build-generated-docs] name: Publish branch build runs-on: ubuntu-latest timeout-minutes: 60 @@ -403,31 +424,23 @@ jobs: node ./publish.js node ./tag-docker-images.js - publish-generated-docs: - needs: [publish] - name: Publish generated docs - runs-on: ubuntu-latest - timeout-minutes: 5 - if: ${{ github.event_name != 'pull_request' }} - steps: - - uses: actions/checkout@v4 - - name: Use Node.js ${{ env.NODE_VERSION }} - uses: actions/setup-node@v4 - with: - node-version: ${{ env.NODE_VERSION }} - - run: npm ci - - name: Generate TypeDoc - run: npm run --prefix shared-libs/cht-datasource gen-docs - - name: Main Branch Only - Deploy to GH pages - uses: peaceiris/actions-gh-pages@v4 - if: github.ref == 'refs/heads/master' - with: - personal_token: ${{ secrets.DEPLOY_TO_GITHUB_PAGES }} - external_repository: medic/cht-datasource - publish_dir: ./shared-libs/cht-datasource/docs - user_name: medic-ci - user_email: medic-ci@github - publish_branch: main + - name: Restore generated docs + if: github.ref == 'refs/heads/master' + uses: actions/cache/restore@v4 + with: + key: cht-datasource-docs + path: ./shared-libs/cht-datasource/docs + fail-on-cache-miss: true + - name: Publish docs to GH pages + if: github.ref == 'refs/heads/master' + uses: peaceiris/actions-gh-pages@v4 + with: + personal_token: ${{ secrets.DEPLOY_TO_GITHUB_PAGES }} + external_repository: medic/cht-datasource + publish_dir: ./shared-libs/cht-datasource/docs + user_name: medic-ci + user_email: medic-ci@github + publish_branch: main upgrade: needs: [publish] diff --git a/api/src/controllers/africas-talking.js b/api/src/controllers/africas-talking.js index 2420a14fff3..044f4f0ea95 100644 --- a/api/src/controllers/africas-talking.js +++ b/api/src/controllers/africas-talking.js @@ -46,7 +46,61 @@ const validateKey = req => { }); }; +/** + * @openapi + * tags: + * - name: SMS + * description: Operations for SMS messaging integrations + */ module.exports = { + /** + * @openapi + * /api/v1/sms/africastalking/incoming-messages: + * post: + * summary: Receive incoming SMS from Africa's Talking + * operationId: v1SmsAfricasTalkingIncomingMessagesPost + * description: > + * Webhook endpoint for receiving incoming SMS messages from the Africa's Talking gateway. + * Requires a valid incoming key passed as a query parameter. See the + * [documentation](/building/messaging/gateways/africas-talking/) for more details. + * tags: [SMS] + * parameters: + * - in: query + * name: key + * required: true + * schema: + * type: string + * description: The configured incoming key for authentication. + * requestBody: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The Africa's Talking message id. + * from: + * type: string + * description: The sender's phone number. + * text: + * type: string + * description: The message content. + * required: [id, from, text] + * responses: + * '200': + * description: Message processing results + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '400': + * $ref: '#/components/responses/BadRequest' + * '403': + * $ref: '#/components/responses/Forbidden' + */ incomingMessages: (req, res) => { return validateKey(req) .then(() => { @@ -63,6 +117,54 @@ module.exports = { .then(results => res.json(results)) .catch(err => serverUtils.error(err, req, res)); }, + /** + * @openapi + * /api/v1/sms/africastalking/delivery-reports: + * post: + * summary: Receive delivery reports from Africa's Talking + * operationId: v1SmsAfricasTalkingDeliveryReportsPost + * description: > + * Webhook endpoint for receiving SMS delivery status reports from the Africa's Talking gateway. + * Requires a valid incoming key passed as a query parameter. See the + * [documentation](/building/messaging/gateways/africas-talking/) for more details. + * tags: [SMS] + * parameters: + * - in: query + * name: key + * required: true + * schema: + * type: string + * description: The configured incoming key for authentication. + * requestBody: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The gateway message reference. + * status: + * enum: [Sent, Submitted, Buffered, Rejected, Success, Failed] + * description: The delivery status from Africa's Talking. + * failureReason: + * type: string + * description: The reason for failure, if applicable. + * required: [id, status] + * responses: + * '200': + * description: Delivery report processing results + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '400': + * $ref: '#/components/responses/BadRequest' + * '403': + * $ref: '#/components/responses/Forbidden' + */ deliveryReports: (req, res) => { return validateKey(req) .then(() => { diff --git a/api/src/controllers/bulk-docs.js b/api/src/controllers/bulk-docs.js index 4fc52d3fa36..8eafaca1351 100644 --- a/api/src/controllers/bulk-docs.js +++ b/api/src/controllers/bulk-docs.js @@ -29,7 +29,75 @@ const interceptResponse = (requestDocs, req, res, response) => { return bulkDocs.formatResults(requestDocs, req.body.docs, response); }; +/** + * @openapi + * tags: + * - name: Bulk + * description: Bulk document operations + */ module.exports = { + /** + * @openapi + * /api/v1/bulk-delete: + * post: + * summary: Bulk delete documents + * operationId: v1BulkDeletePost + * description: | + * Bulk delete endpoint for deleting large numbers of documents. Docs are batched into groups + * of 100 and sent sequentially to CouchDB. The response is chunked JSON (one batch at a + * time), so to get an indication of progress incomplete JSON must be parsed with a library such as + * [`partial-json-parser`](https://github.com/indgov/partial-json-parser). + * + * ### Errors + * + * If an error is encountered part-way through the response (eg on the third batch), it’s impossible to send + * new headers to indicate a 5xx error, so the connection will simply be terminated (as recommended here + * https://github.com/expressjs/express/issues/2700). + * tags: [Bulk] + * x-permissions: + * hasAll: [can_edit] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [docs] + * properties: + * docs: + * type: array + * description: Array of objects each with an `_id` property. + * items: + * type: object + * additionalProperties: true + * required: [_id] + * properties: + * _id: + * type: string + * responses: + * '200': + * description: > + * Chunked JSON response. Each chunk is an array of results for a batch of deletions. + * content: + * application/json: + * schema: + * type: array + * items: + * type: array + * items: + * type: object + * properties: + * ok: + * type: boolean + * id: + * type: string + * rev: + * type: string + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ bulkDelete: (req, res, next) => { return auth .check(req, ['can_edit']) diff --git a/api/src/controllers/contact.js b/api/src/controllers/contact.js index e2dad837a81..2a1d6d07dba 100644 --- a/api/src/controllers/contact.js +++ b/api/src/controllers/contact.js @@ -7,8 +7,50 @@ const getContact = ctx.bind(Contact.v1.get); const getContactWithLineage = ctx.bind(Contact.v1.getWithLineage); const getContactIds = ctx.bind(Contact.v1.getUuidsPage); +/** + * @openapi + * tags: + * - name: Contact + * description: Operations for contacts (persons and places) + */ module.exports = { v1: { + /** + * @openapi + * /api/v1/contact/{id}: + * get: + * summary: Get a contact by id + * operationId: v1ContactIdGet + * description: > + * Returns a contact record (person or place). Optionally includes the full parent place lineage. + * tags: [Contact] + * x-since: 4.18.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the contact to retrieve + * - $ref: '#/components/parameters/withLineage' + * responses: + * '200': + * description: The contact record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Contact' + * - $ref: '#/components/schemas/v1.ContactWithLineage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); const { params: { uuid }, query: { with_lineage } } = req; @@ -20,6 +62,61 @@ module.exports = { return res.json(contact); }), + + /** + * @openapi + * /api/v1/contact/uuid: + * get: + * summary: Get contact UUIDs + * operationId: v1ContactUuidGet + * description: > + * Returns a paginated array of contact identifier strings matching the given filter criteria. + * At least one of `type` or `freetext` must be provided. + * tags: [Contact] + * x-since: 4.18.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: query + * name: type + * schema: + * type: string + * description: > + * The contact_type id for the type of contacts to fetch. Required if `freetext` is not provided + * and may be combined with `freetext`. + * - in: query + * name: freetext + * schema: + * type: string + * minLength: 3 + * description: > + * A search term for filtering contacts. Must be at least 3 characters and not contain whitespace. + * Required if `type` is not provided and may be combined with `type`. + * - $ref: '#/components/parameters/cursor' + * - $ref: '#/components/parameters/limitId' + * responses: + * '200': + * description: A page of contact UUIDs + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * description: The results for this page + * items: + * type: string + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ getUuids: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); if (!req.query.freetext && !req.query.type) { diff --git a/api/src/controllers/contacts-by-phone.js b/api/src/controllers/contacts-by-phone.js index 536840dc0d6..08841b4e58d 100644 --- a/api/src/controllers/contacts-by-phone.js +++ b/api/src/controllers/contacts-by-phone.js @@ -14,6 +14,92 @@ const getPhoneNumber = (req) => { }; module.exports = { + /** + * @openapi + * /api/v1/contacts-by-phone: + * get: + * summary: Find contacts by phone number + * operationId: v1ContactsByPhoneGet + * description: > + * Accepts a phone number and returns fully hydrated contacts that match. If multiple + * contacts are found, all are returned. Returns 404 when no matches are found. + * tags: [Contact] + * x-since: 3.10.0 + * parameters: + * - in: query + * name: phone + * required: true + * schema: + * type: string + * description: A URL-encoded string representing a phone number. + * responses: + * '200': + * description: Matching contacts found + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * docs: + * type: array + * description: Fully hydrated matching contacts. + * items: + * type: object + * additionalProperties: true + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + * post: + * summary: Find contacts by phone number (POST) + * operationId: v1ContactsByPhonePost + * description: > + * Accepts a phone number and returns fully hydrated contacts that match. If multiple + * contacts are found, all are returned. Returns 404 when no matches are found. + * tags: [Contact] + * x-since: 3.10.0 + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [phone] + * properties: + * phone: + * type: string + * description: A string representing a phone number. + * responses: + * '200': + * description: Matching contacts found + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * docs: + * type: array + * description: Fully hydrated matching contacts. + * items: + * type: object + * additionalProperties: true + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ request: (req, res) => { const phone = getPhoneNumber(req); if (!phone) { diff --git a/api/src/controllers/credentials.js b/api/src/controllers/credentials.js index 1fe2cfdb583..acd5a719669 100644 --- a/api/src/controllers/credentials.js +++ b/api/src/controllers/credentials.js @@ -15,6 +15,47 @@ const checkAuth = req => { }; module.exports = { + /** + * @openapi + * /api/v1/credentials/{key}: + * put: + * summary: Set a credential + * operationId: v1CredentialsKeyPut + * description: > + * Securely store a credential for authentication with third-party systems such as SMS + * aggregators and HMIS. The credential key is provided as a path parameter and the + * password as plain text in the request body. Only database admins can access this + * endpoint. + * tags: [Config] + * x-since: 4.0.0 + * parameters: + * - in: path + * name: key + * required: true + * schema: + * type: string + * description: The credential key identifier. + * requestBody: + * required: true + * content: + * text/plain: + * schema: + * type: string + * description: The credential password/secret value. + * responses: + * '200': + * description: Credential stored successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ put: (req, res) => { const key = req.params.key; const password = req.body; diff --git a/api/src/controllers/export-data.js b/api/src/controllers/export-data.js index 6ef04ca4287..e71bf4de5c2 100644 --- a/api/src/controllers/export-data.js +++ b/api/src/controllers/export-data.js @@ -8,10 +8,6 @@ const service = require('../services/export-data'); const writeExportHeaders = (res, type, format) => { const formats = { - xml: { - extension: 'xml', - contentType: 'application/vnd.ms-excel' - }, csv: { extension: 'csv', contentType: 'text/csv' @@ -41,7 +37,567 @@ const getVerifiedValue = (value) => { return null; }; +/** + * @openapi + * tags: + * - name: Export + * description: Export data in various formats + * components: + * schemas: + * ExportOptions: + * type: object + * properties: + * humanReadable: + * type: boolean + * description: Set to true to format dates as ISO 8601 instead of epoch timestamps. + * additionalProperties: true + * UserDevice: + * type: object + * properties: + * user: + * type: string + * description: The user's name. + * deviceId: + * type: string + * description: The unique key for the user's device. + * date: + * type: string + * description: > + * The date the telemetry entry was taken (YYYY-MM-DD), see + * [relevant docs](/technical-overview/data/performance/telemetry/). + * browser: + * type: object + * properties: + * name: + * type: string + * description: The name of the browser used. + * version: + * type: string + * description: The version of the browser used. + * apk: + * type: string + * description: > + * The [version code](https://developer.android.com/reference/android/R.styleable#AndroidManifest_versionCode) + * of the Android app. + * android: + * type: string + * description: The version of Android OS. + * cht: + * type: string + * description: The version of CHT at time of telemetry. + * settings: + * type: string + * description: The revision of the App Settings document. + * storageFree: + * type: number + * description: Free storage space on the device in bytes. Added in CHT 5.0.0. + * storageTotal: + * type: number + * description: Total storage capacity of the device in bytes. Added in CHT 5.0.0. + * parameters: + * exportOptionsQuery: + * in: query + * name: options + * description: Export options. + * style: deepObject + * explode: true + * schema: + * type: object + * properties: + * humanReadable: + * enum: ['true', 'false'] + * default: 'false' + * description: Set to "true" to format dates as ISO 8601 instead of epoch timestamps. + * responses: + * CsvExport: + * description: The exported data as a CSV file download + * content: + * text/csv: + * schema: + * type: string + * format: binary + * UserDeviceExport: + * description: User device information + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/UserDevice' + */ module.exports = { + /** + * @openapi + * /api/v2/export/dhis: + * get: + * summary: Export DHIS2 target data + * operationId: v2ExportDhisGet + * description: > + * Exports target data formatted as a DHIS2 dataValueSet. The data can be filtered to a specific section + * of the contact hierarchy or for a given time interval. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_dhis] + * parameters: + * - in: query + * name: filters + * schema: + * type: object + * properties: + * dataSet: + * type: string + * description: > + * A DHIS2 dataSet ID. Targets associated with this dataSet will have their data aggregated. + * date: + * type: object + * required: [from] + * properties: + * from: + * type: number + * description: Filter the target data to be within the month of this timestamp. + * orgUnit: + * type: string + * description: > + * Filter the target data to only that associated with contacts with attribute + * `{ dhis: { orgUnit } }`. + * required: [dataSet, date] + * style: deepObject + * explode: true + * description: Filters for the DHIS2 export. + * - $ref: '#/components/parameters/exportOptionsQuery' + * responses: + * '200': + * description: DHIS2 dataValueSet + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export DHIS2 target data + * operationId: v2ExportDhisPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/dhis](#/Export/v2ExportDhisGet) instead. + * Exports target data formatted as a DHIS2 dataValueSet. Accepts filters in the request body. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_dhis] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * filters: + * type: object + * properties: + * dataSet: + * type: string + * description: A DHIS2 dataSet ID. + * date: + * type: object + * required: [from] + * properties: + * from: + * type: number + * description: Filter target data to be within the month of this timestamp. + * orgUnit: + * type: string + * description: Filter by contacts with this DHIS2 orgUnit attribute. + * required: [dataSet, date] + * options: + * $ref: '#/components/schemas/ExportOptions' + * responses: + * '200': + * description: DHIS2 dataValueSet + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/export/reports: + * get: + * summary: Export reports + * operationId: v2ExportReportsGet + * description: > + * Exports reports as CSV. Uses the + * [search library](https://github.com/medic/cht-core/tree/master/shared-libs/search) to ensure identical + * results to the front-end. Filters can be passed as query parameters using form-style encoding + * (e.g. `filters[forms][selected][0][code]=immunization_visit`). + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_messages] + * parameters: + * - in: query + * name: filters + * schema: + * type: object + * properties: + * search: + * type: string + * description: A freetext search term. + * forms: + * type: object + * description: Filter by form codes. + * properties: + * selected: + * type: array + * items: + * type: object + * properties: + * code: + * type: string + * date: + * type: object + * properties: + * from: + * type: number + * description: Start of date range (epoch timestamp). + * to: + * type: number + * description: End of date range (epoch timestamp). + * verified: + * type: string + * description: Filter by verification status ("true", "false"). + * valid: + * type: string + * description: Filter by validity ("true"). + * additionalProperties: true + * style: deepObject + * explode: true + * description: Filters for the reports export. + * - $ref: '#/components/parameters/exportOptionsQuery' + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export reports + * operationId: v2ExportReportsPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/reports](#/Export/v2ExportReportsGet) instead. + * Exports reports as CSV. Accepts filters in the request body. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_messages] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * filters: + * type: object + * properties: + * search: + * type: string + * description: A freetext search term. + * forms: + * type: object + * description: Filter by form codes. + * properties: + * selected: + * type: array + * items: + * type: object + * properties: + * code: + * type: string + * date: + * type: object + * properties: + * from: + * type: number + * description: Start of date range (epoch timestamp). + * to: + * type: number + * description: End of date range (epoch timestamp). + * verified: + * description: Filter by verification status. + * oneOf: + * - type: boolean + * - type: array + * items: + * type: boolean + * valid: + * type: boolean + * description: Filter by validity. + * additionalProperties: true + * options: + * $ref: '#/components/schemas/ExportOptions' + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/export/messages: + * get: + * summary: Export messages + * operationId: v2ExportMessagesGet + * description: | + * Exports messages as CSV. + * + * ### Response Columns + * + * | Column | Description | + * | ------------------ | ------------------------------------------------------------------------------------ | + * | Record UUID | The unique ID for the message in the database. | + * | Patient ID | The generated short patient ID for use in SMS. | + * | Reported Date | The date the message was received or generated. | + * | From | This phone number the message is or will be sent from. | + * | Contact Name | The name of the user this message is assigned to. | + * | Message Type | The type of the message | + * | Message State | The state of the message at the time this export was generated | + * | Received Timestamp | The datetime the message was received. Only applies to incoming messages. | + * | Other Timestamps | The datetime the message transitioned to each state. | + * | Sent By | The phone number the message was sent from. Only applies to incoming messages. | + * | To Phone | The phone number the message is or will be sent to. Only applies to outgoing. | + * | Message Body | The content of the message. | + * + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_messages] + * parameters: + * - in: query + * name: filters + * schema: + * type: object + * properties: + * date: + * type: object + * properties: + * from: + * type: number + * description: Start of date range (epoch timestamp). + * to: + * type: number + * description: End of date range (epoch timestamp). + * additionalProperties: true + * style: deepObject + * explode: true + * description: Filters for the messages export. + * - $ref: '#/components/parameters/exportOptionsQuery' + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export messages + * operationId: v2ExportMessagesPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/messages](#/Export/v2ExportMessagesGet) instead. + * Exports messages as CSV. Accepts filters in the request body. See + * [GET](#/Export/v2ExportMessagesGet) for output column details. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_messages] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * filters: + * type: object + * properties: + * date: + * type: object + * properties: + * from: + * type: number + * description: Start of date range (epoch timestamp). + * to: + * type: number + * description: End of date range (epoch timestamp). + * additionalProperties: true + * options: + * $ref: '#/components/schemas/ExportOptions' + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/export/contacts: + * get: + * summary: Export contacts + * operationId: v2ExportContactsGet + * description: | + * Exports contacts as a CSV. Filters use the same query format as the + * [search library](https://github.com/medic/cht-core/tree/master/shared-libs/search). + * + * ### Response Columns + * + * | Column | Description | + * | -------------| -------------------------------------------------------------------------------| + * | id | The unique ID for the contact in the database. | + * | rev | The current CouchDb revision of contact in the database. | + * | name | The name of the user this message is assigned to. | + * | patient_id | The generated short patient ID for use in SMS. | + * | type | The contact type. For configurable hierarchies, this will always be `contact`. | + * | contact_type | The configurable contact type. Will be empty if using the default hierarchy. | + * | place_id | The generated short place ID for use in SMS. | + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_contacts] + * parameters: + * - in: query + * name: filters + * schema: + * type: object + * properties: + * search: + * type: string + * description: A freetext search term. (e.g. `GET /api/v2/export/contacts?filters[search]=jim`) + * additionalProperties: true + * style: deepObject + * explode: true + * description: Filters for the contacts export. + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export contacts + * operationId: v2ExportContactsPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/contacts](#/Export/v2ExportContactsGet) instead. + * Exports contacts as a CSV. Accepts filters in the request body. See [GET](#/Export/v2ExportContactsGet) for + * output column details. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_contacts] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * properties: + * filters: + * type: object + * properties: + * search: + * type: string + * description: A freetext search term. + * additionalProperties: true + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/export/feedback: + * get: + * summary: Export feedback + * operationId: v2ExportFeedbackGet + * description: Exports user feedback data as CSV. + * tags: [Export] + * x-permissions: + * hasAny: [can_export_all, can_export_feedback] + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export feedback + * operationId: v2ExportFeedbackPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/feedback](#/Export/v2ExportFeedbackGet) instead. + * Exports user feedback data as CSV. + * tags: [Export] + * x-permissions: + * isOnline: true + * hasAny: [can_export_all, can_export_feedback] + * requestBody: + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * responses: + * '200': + * $ref: '#/components/responses/CsvExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/export/user-devices: + * get: + * summary: Export user device information + * operationId: v2ExportUserDevicesGet + * description: | + * This endpoint is deprecated as it can negatively impact server performance. Deployments should avoid using + * this endpoint or use it only when end users will not be impacted. An improved endpoint is + * [being planned](https://github.com/medic/cht-core/issues/10298) for a later date. + * + * Returns a JSON array of CHT-related software versions and device information for each user device. + * This information is derived from the latest telemetry entry for each user device. + * + * If a particular user has used multiple devices, an entry will be included for each device. Reference the + * date value to determine which devices have been recently used. If multiple users used the same physical + * device (e.g. they were logged into the same phone at different times), an entry will be included for each + * user. + * tags: [Export] + * deprecated: true + * x-since: 4.7.0 + * x-permissions: + * hasAny: [can_export_all, can_export_devices_details] + * responses: + * '200': + * $ref: '#/components/responses/UserDeviceExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Export user device information + * operationId: v2ExportUserDevicesPost + * deprecated: true + * description: > + * Use [GET /api/v2/export/user-devices](#/Export/v2ExportUserDevicesGet) instead. + * Returns a JSON array of CHT-related software versions and device information for each user device. + * This endpoint may negatively impact server performance. + * tags: [Export] + * x-since: 4.7.0 + * x-permissions: + * hasAny: [can_export_all, can_export_devices_details] + * responses: + * '200': + * $ref: '#/components/responses/UserDeviceExport' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ get: (req, res) => { /** * Integer values get parsed in by express as strings. This will not do! diff --git a/api/src/controllers/forms.js b/api/src/controllers/forms.js index 6d6044894fc..edff50c031b 100644 --- a/api/src/controllers/forms.js +++ b/api/src/controllers/forms.js @@ -55,6 +55,67 @@ const listFormsJSON = forms => { }; module.exports = { + /** + * @openapi + * /api/v1/forms: + * get: + * summary: List installed forms + * operationId: v1FormsGet + * description: > + * Returns a list of currently installed forms. By default returns a JSON array of form filenames. If the + * `X-OpenRosa-Version` header is set to `1.0`, returns an OpenRosa `xformsList` compatible XML response + * instead. + * tags: [Config] + * parameters: + * - in: header + * name: X-OpenRosa-Version + * schema: + * enum: ['1.0'] + * description: > + * If set to "1.0", returns XML formatted forms list compatible with the + * [OpenRosa FormListAPI](https://bitbucket.org/javarosa/javarosa/wiki/FormListAPI). + * responses: + * '200': + * description: List of installed forms + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * example: ["anc_visit.xml", "anc_registration.xml"] + * text/xml: + * schema: + * type: object + * xml: + * name: xforms + * namespace: 'http://openrosa.org/xforms/xformsList' + * description: OpenRosa xformsList compatible XML. + * properties: + * xform: + * type: array + * xml: + * wrapped: false + * items: + * type: object + * xml: + * name: xform + * properties: + * name: + * type: string + * example: Visit + * formID: + * type: string + * example: ANCVisit + * hash: + * type: string + * example: 'md5:1f0f096602ed794a264ab67224608cf4' + * downloadUrl: + * type: string + * example: 'http://medic.local/api/v1/forms/anc_visit.xml' + * '401': + * $ref: '#/components/responses/Unauthorized' + */ list: (req, res) => { if (!req.userCtx) { return serverUtils.notLoggedIn(req, res); @@ -72,6 +133,39 @@ module.exports = { }) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v1/forms/{form}: + * get: + * summary: Get a form definition + * operationId: v1FormsFormGet + * description: > + * Returns the form definition for a given form ID and format. The form parameter should + * include the format extension (e.g. `pregnancyregistration.xml`). Currently only `xml` + * format is supported. + * tags: [Config] + * parameters: + * - in: path + * name: form + * required: true + * schema: + * type: string + * description: "Form identifier with format extension (e.g. `pregnancyregistration.xml`)." + * responses: + * '200': + * description: The form definition + * content: + * text/xml: + * schema: + * type: string + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: (req, res) => { if (!req.userCtx) { return serverUtils.notLoggedIn(req, res); @@ -110,6 +204,48 @@ module.exports = { }) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v1/forms/validate: + * post: + * summary: Validate an XForm + * operationId: v1FormsValidatePost + * description: > + * Validates the XForm XML passed in the request body. + * tags: [Config] + * x-since: 3.12.0 + * x-permissions: + * hasAll: [can_configure] + * requestBody: + * required: true + * content: + * application/xml: + * schema: + * type: string + * description: The XForm XML to validate. + * responses: + * '200': + * description: Form validation passed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * description: Form validation failed + * content: + * application/json: + * schema: + * type: object + * properties: + * error: + * type: string + * description: Description of the validation error. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ validate: (req, res) => { return auth .check(req, 'can_configure') diff --git a/api/src/controllers/hydration.js b/api/src/controllers/hydration.js index 57fe92e847a..c0b8a658573 100644 --- a/api/src/controllers/hydration.js +++ b/api/src/controllers/hydration.js @@ -47,7 +47,102 @@ const formatResponse = (docIds, hydratedDocs) => { }); }; +/** + * @openapi + * components: + * schemas: + * HydrationResult: + * type: object + * required: [id] + * properties: + * id: + * type: string + * description: The document uuid. + * doc: + * type: object + * additionalProperties: true + * description: The fully hydrated document. Present when the document is found. + * error: + * type: string + * description: '"not_found" when the document is not found.' + */ module.exports = { + /** + * @openapi + * /api/v1/hydrate: + * get: + * summary: Hydrate documents by id (GET) + * operationId: v1HydrateGet + * description: > + * Accepts a JSON array of document uuids and returns fully hydrated documents, in the same + * order in which they were requested. When documents are not found, an entry with the + * missing uuid and an error is added instead. + * tags: [Bulk] + * parameters: + * - in: query + * name: doc_ids + * required: true + * description: A JSON-encoded array of document uuids. + * content: + * application/json: + * schema: + * type: array + * items: + * type: string + * responses: + * '200': + * description: Hydrated documents + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/HydrationResult' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * post: + * summary: Hydrate documents by id (POST) + * operationId: v1HydratePost + * description: > + * Accepts a JSON array of document uuids and returns fully hydrated documents, in the same + * order in which they were requested. When documents are not found, an entry with the + * missing uuid and an error is added instead. + * tags: [Bulk] + * x-permissions: + * isOnline: true + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [doc_ids] + * properties: + * doc_ids: + * type: array + * description: A JSON array of document uuids. + * items: + * type: string + * responses: + * '200': + * description: Hydrated documents + * content: + * application/json: + * schema: + * type: array + * items: + * $ref: '#/components/schemas/HydrationResult' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ hydrate: (req, res) => { const docIds = getDocIds(req); if (!docIds) { diff --git a/api/src/controllers/impact.js b/api/src/controllers/impact.js index fc731926149..a7b1fd00130 100644 --- a/api/src/controllers/impact.js +++ b/api/src/controllers/impact.js @@ -9,8 +9,73 @@ const checkUserPermissions = async (req) => { throw new PermissionError('Insufficient privileges'); } }; + module.exports = { v1: { + /** + * @openapi + * /api/v1/impact: + * get: + * summary: Get impact metrics + * operationId: v1ImpactGet + * description: Returns aggregated impact metrics including user, contact, and report counts. + * tags: [Monitoring] + * x-since: 5.0.0 + * responses: + * '200': + * description: The impact metrics + * content: + * application/json: + * schema: + * type: object + * properties: + * users: + * type: object + * properties: + * count: + * type: number + * description: Total number of users. + * contacts: + * type: object + * properties: + * count: + * type: number + * description: Total number of contacts. + * by_type: + * type: array + * description: Contact counts broken down by contact type. + * items: + * type: object + * properties: + * type: + * type: string + * description: Name of the contact type. + * count: + * type: number + * description: Total number of contacts with the type. + * reports: + * type: object + * properties: + * count: + * type: number + * description: Total number of reports. + * by_form: + * type: array + * description: Report counts broken down by form. + * items: + * type: object + * properties: + * form: + * type: string + * description: Name of the form. + * count: + * type: number + * description: Total number of reports with the form. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ get: serverUtils.doOrError(async (req, res) => { await checkUserPermissions(req); const impact = await service.jsonV1(); diff --git a/api/src/controllers/monitoring.js b/api/src/controllers/monitoring.js index 5e034f8dd68..8fec70a10ed 100644 --- a/api/src/controllers/monitoring.js +++ b/api/src/controllers/monitoring.js @@ -3,7 +3,214 @@ const serverUtils = require('../server-utils'); const DEFAULT_CONNECTED_USER_INTERVAL = 7; +/** + * @openapi + * tags: + * - name: Monitoring + * description: Operations for monitoring the CHT instance + * components: + * schemas: + * MonitoringVersion: + * type: object + * properties: + * app: + * type: string + * description: The version of the webapp. + * node: + * type: string + * description: The version of NodeJS. + * couchdb: + * type: string + * description: The version of CouchDB. + * MonitoringCouchDb: + * type: object + * description: > + * CouchDB metrics keyed by database name (e.g. "medic", "medic-sentinel", + * "medic-users-meta", "_users"). + * additionalProperties: + * type: object + * properties: + * name: + * type: string + * description: The name of the db. + * update_sequence: + * type: number + * description: The number of changes in the db. + * doc_count: + * type: number + * description: The number of docs in the db. + * doc_del_count: + * type: number + * description: The number of deleted docs in the db. + * fragmentation: + * type: number + * description: > + * The fragmentation of the entire db (including view indexes) as stored on disk. + * A lower value is better. `1` is no fragmentation. + * sizes: + * type: object + * description: Database size information. Requires CHT Core `4.11.0` or later. + * properties: + * active: + * type: number + * description: > + * The size in bytes of live data inside the database. Includes documents, + * metadata, and attachments, but not view indexes. + * file: + * type: number + * description: > + * The size in bytes of the database file on disk. Includes documents, + * metadata, and attachments, but not view indexes. + * view_indexes: + * type: array + * description: View index information. Requires CHT Core `4.11.0` or later. + * items: + * type: object + * properties: + * name: + * type: string + * description: The name of the view index (the design). + * sizes: + * type: object + * properties: + * active: + * type: number + * description: The size in bytes of live data inside the view. + * file: + * type: number + * description: The size in bytes of the view as stored on disk. + * MonitoringDate: + * type: object + * properties: + * current: + * type: number + * description: > + * The current server date in millis since the epoch, useful for ensuring the + * server time is correct. + * uptime: + * type: number + * description: How long API has been running in seconds. + * MonitoringSentinel: + * type: object + * properties: + * backlog: + * type: number + * description: Number of changes yet to be processed by Sentinel. + * MonitoringOutboundPush: + * type: object + * properties: + * backlog: + * type: number + * description: Number of changes yet to be processed by Outbound Push. + * MonitoringFeedback: + * type: object + * properties: + * count: + * type: number + * description: > + * Number of feedback docs created, usually indicative of client side errors. + * MonitoringConflict: + * type: object + * properties: + * count: + * type: number + * description: Number of doc conflicts which need to be resolved manually. + * MonitoringReplicationLimit: + * type: object + * properties: + * count: + * type: number + * description: Number of users that exceeded the replication limit of documents. + * MonitoringConnectedUsers: + * type: object + * properties: + * count: + * type: number + * description: > + * Number of users that have connected to the api in a given number of days. + * The period defaults to 7 days but can be changed via the + * `connected_user_interval` query parameter. + */ module.exports = { + /** + * @openapi + * /api/v1/monitoring: + * get: + * summary: Get monitoring metrics + * operationId: v1MonitoringGet + * deprecated: true + * description: > + * Use [GET /api/v2/monitoring](#/Monitoring/v2MonitoringGet) instead. + * Returns a range of metrics about the instance for automated monitoring, allowing tracking of trends over + * time and alerting about potential issues. No authentication is required. + * + * Errors: + * + * - A metric of `""` (for string values) or `-1` (for numeric values) indicates an error occurred while + * querying the metric - check the API logs for details. + * - If no response or an error response is received the instance is unreachable. Thus, this API can be used + * as an uptime monitoring endpoint. + * tags: [Monitoring] + * parameters: + * - in: query + * name: connected_user_interval + * schema: + * type: number + * default: 7 + * description: The number of days to use when counting connected users + * responses: + * '200': + * description: Monitoring metrics + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * $ref: '#/components/schemas/MonitoringVersion' + * couchdb: + * $ref: '#/components/schemas/MonitoringCouchDb' + * date: + * $ref: '#/components/schemas/MonitoringDate' + * sentinel: + * $ref: '#/components/schemas/MonitoringSentinel' + * messaging: + * type: object + * properties: + * outgoing: + * type: object + * properties: + * state: + * type: object + * properties: + * due: + * type: number + * description: The number of messages due to be sent. + * scheduled: + * type: number + * description: The number of messages scheduled to be sent in the future. + * muted: + * type: number + * description: > + * The number of messages that are muted and therefore will not be sent. + * delivered: + * type: number + * description: > + * The number of messages that have been delivered or sent. As of 3.12.x. + * failed: + * type: number + * description: The number of messages that have failed to be delivered. As of 3.12.x. + * outbound_push: + * $ref: '#/components/schemas/MonitoringOutboundPush' + * feedback: + * $ref: '#/components/schemas/MonitoringFeedback' + * conflict: + * $ref: '#/components/schemas/MonitoringConflict' + * replication_limit: + * $ref: '#/components/schemas/MonitoringReplicationLimit' + * connected_users: + * $ref: '#/components/schemas/MonitoringConnectedUsers' + */ getV1: (req, res) => { const connectedUserInterval = req.query.connected_user_interval || DEFAULT_CONNECTED_USER_INTERVAL; @@ -11,9 +218,159 @@ module.exports = { .then(body => res.json(body)) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v2/monitoring: + * get: + * summary: Get monitoring metrics + * operationId: v2MonitoringGet + * description: > + * Returns a range of metrics about the instance for automated monitoring, allowing tracking of trends over + * time and alerting about potential issues. No authentication is required. + * tags: [Monitoring] + * x-since: 3.12.0 + * parameters: + * - in: query + * name: connected_user_interval + * schema: + * type: number + * default: 7 + * description: The number of days to use when counting connected users + * responses: + * '200': + * description: Monitoring metrics + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * $ref: '#/components/schemas/MonitoringVersion' + * couchdb: + * $ref: '#/components/schemas/MonitoringCouchDb' + * date: + * $ref: '#/components/schemas/MonitoringDate' + * sentinel: + * $ref: '#/components/schemas/MonitoringSentinel' + * messaging: + * type: object + * properties: + * outgoing: + * type: object + * properties: + * total: + * type: object + * properties: + * due: + * type: number + * description: The number of messages due to be sent. + * scheduled: + * type: number + * description: The number of messages scheduled to be sent in the future. + * muted: + * type: number + * description: > + * The number of messages that are muted and therefore will not be sent. + * delivered: + * type: number + * description: The number of messages that have been delivered or sent. + * failed: + * type: number + * description: The number of messages that have failed to be delivered. + * seven_days: + * type: object + * properties: + * due: + * type: number + * description: The number of messages due to be sent in the last seven days. + * scheduled: + * type: number + * description: > + * The number of messages that were scheduled to be sent in the last seven days. + * muted: + * type: number + * description: > + * The number of messages that were due in the last seven days and are muted. + * delivered: + * type: number + * description: > + * The number of messages that were due in the last seven days and have been + * delivered or sent. + * failed: + * type: number + * description: > + * The number of messages that were due in the last seven days and have failed + * to be delivered. + * last_hundred: + * type: object + * properties: + * pending: + * type: object + * description: > + * Counts within last 100 messages that have received status updates, and are + * one of the "pending" group statuses. + * properties: + * pending: + * type: number + * description: Number of messages that are pending. + * forwarded-to-gateway: + * type: number + * description: Number of messages that are forwarded-to-gateway. + * received-by-gateway: + * type: number + * description: Number of messages that are received-by-gateway. + * forwarded-by-gateway: + * type: number + * description: Number of messages that are forwarded-by-gateway. + * final: + * type: object + * description: > + * Counts within last 100 messages that have received status updates, and are + * in one of the "final" group statuses. + * properties: + * sent: + * type: number + * description: Number of messages that are sent. + * delivered: + * type: number + * description: Number of messages that are delivered. + * failed: + * type: number + * description: Number of messages that are failed. + * denied: + * type: number + * description: Number of messages that are denied. + * cleared: + * type: number + * description: Number of messages that are cleared. + * muted: + * type: number + * description: Number of messages that are muted. + * duplicate: + * type: number + * description: Number of messages that are duplicate. + * muted: + * type: object + * description: > + * Counts within last 100 messages that have received status updates, and are + * in one of the "muted" group statuses. + * additionalProperties: + * type: number + * outbound_push: + * $ref: '#/components/schemas/MonitoringOutboundPush' + * feedback: + * $ref: '#/components/schemas/MonitoringFeedback' + * conflict: + * $ref: '#/components/schemas/MonitoringConflict' + * replication_limit: + * $ref: '#/components/schemas/MonitoringReplicationLimit' + * connected_users: + * $ref: '#/components/schemas/MonitoringConnectedUsers' + */ getV2: (req, res) => { const connectedUserInterval = req.query.connected_user_interval || DEFAULT_CONNECTED_USER_INTERVAL; - + return service .jsonV2(connectedUserInterval) .then(body => res.json(body)) diff --git a/api/src/controllers/person.js b/api/src/controllers/person.js index b40d250cbee..f9468ab39e5 100644 --- a/api/src/controllers/person.js +++ b/api/src/controllers/person.js @@ -9,8 +9,49 @@ const getPage = ctx.bind(Person.v1.getPage); const createPerson = ctx.bind(Person.v1.create); const updatePerson = ctx.bind(Person.v1.update); +/** + * @openapi + * tags: + * - name: Person + * description: Operations for person contacts + */ module.exports = { v1: { + /** + * @openapi + * /api/v1/person/{id}: + * get: + * summary: Get a person by id + * operationId: v1PersonIdGet + * description: Returns a person contact record. Optionally includes the full parent place lineage. + * tags: [Person] + * x-since: 4.9.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the person to retrieve + * - $ref: '#/components/parameters/withLineage' + * responses: + * '200': + * description: The person record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Person' + * - $ref: '#/components/schemas/v1.PersonWithLineage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); const { params: { uuid }, query: { with_lineage } } = req; @@ -22,6 +63,53 @@ module.exports = { return res.json(person); }), + + /** + * @openapi + * /api/v1/person: + * get: + * summary: Get persons + * operationId: v1PersonGet + * description: > + * Returns a paginated array of persons for the given contact type. Use the `cursor` returned in each response + * to retrieve subsequent pages. See also [Get Person by id](#/Person/v1PersonIdGet) for retrieving a single + * person. + * tags: [Person] + * x-since: 4.11.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: query + * name: type + * required: true + * schema: + * type: string + * description: The contact_type id for the type of persons to fetch + * - $ref: '#/components/parameters/cursor' + * - $ref: '#/components/parameters/limitEntity' + * responses: + * '200': + * description: A page of person records + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * description: The results for this page + * items: + * $ref: '#/components/schemas/v1.Person' + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ getAll: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); const personType = Qualifier.byContactType(req.query.type); @@ -29,12 +117,87 @@ module.exports = { return res.json(docs); }), + /** + * @openapi + * /api/v1/person: + * post: + * summary: Create a new person + * operationId: v1PersonPost + * description: Creates a new person record. + * tags: [Person] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_create_people, can_edit] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.PersonInput' + * responses: + * '200': + * description: The created person record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.Person' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_create_people', 'can_edit'] }); const personDoc = await createPerson(req.body); return res.json(personDoc); }), + /** + * @openapi + * /api/v1/person/{id}: + * put: + * summary: Update a person + * operationId: v1PersonIdPut + * description: > + * Updates an existing person contact record. Fields omitted on the request will be removed from the record. + * Any included lineage data will be minified on the stored record. + * tags: [Person] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_update_people, can_edit] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the person to update + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.UpdatePersonInput' + * responses: + * '200': + * description: The updated person record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Person' + * - $ref: '#/components/schemas/v1.PersonWithLineage' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ update: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_update_people', 'can_edit'] }); const { params: { uuid }, body } = req; diff --git a/api/src/controllers/place.js b/api/src/controllers/place.js index 2890d81ffb0..02dfc657f9d 100644 --- a/api/src/controllers/place.js +++ b/api/src/controllers/place.js @@ -9,8 +9,49 @@ const getPageByType = ctx.bind(Place.v1.getPage); const create = ctx.bind(Place.v1.create); const update = ctx.bind(Place.v1.update); +/** + * @openapi + * tags: + * - name: Place + * description: Operations for place contacts + */ module.exports = { v1: { + /** + * @openapi + * /api/v1/place/{id}: + * get: + * summary: Get a place by id + * operationId: v1PlaceIdGet + * description: Returns a place contact record. Optionally includes the full parent place lineage. + * tags: [Place] + * x-since: 4.10.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the place to retrieve + * - $ref: '#/components/parameters/withLineage' + * responses: + * '200': + * description: The place record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Place' + * - $ref: '#/components/schemas/v1.PlaceWithLineage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); const { params: { uuid }, query: { with_lineage } } = req; @@ -22,6 +63,52 @@ module.exports = { return res.json(place); }), + /** + * @openapi + * /api/v1/place: + * get: + * summary: Get places + * operationId: v1PlaceGet + * description: > + * Returns a paginated array of places for the given contact type. Use the `cursor` returned in each response + * to retrieve subsequent pages. See also [Get Place by id](#/Place/v1PlaceIdGet) for retrieving a single + * place. + * tags: [Place] + * x-since: 4.12.0 + * x-permissions: + * hasAll: [can_view_contacts] + * parameters: + * - in: query + * name: type + * required: true + * schema: + * type: string + * description: The contact_type id for the type of places to fetch + * - $ref: '#/components/parameters/cursor' + * - $ref: '#/components/parameters/limitEntity' + * responses: + * '200': + * description: A page of place records + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * description: The results for this page + * items: + * $ref: '#/components/schemas/v1.Place' + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ getAll: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_contacts'] }); const placeType = Qualifier.byContactType(req.query.type); @@ -29,12 +116,95 @@ module.exports = { return res.json(docs); }), + /** + * @openapi + * /api/v1/place: + * post: + * summary: Create a new place + * operationId: v1PlacePost + * description: Creates a new place record. + * tags: [Place] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_create_places, can_edit] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.PlaceInput' + * responses: + * '200': + * description: The created place record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.Place' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_create_places', 'can_edit'] }); const placeDoc = await create(req.body); return res.json(placeDoc); }), + /** + * @openapi + * /api/v1/place/{id}: + * put: + * summary: Update a place + * operationId: v1PlaceIdPut + * description: > + * Updates an existing place contact record. Fields omitted on the request will be removed from the record. + * Any included lineage data will be minified on the stored record. + * tags: [Place] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_update_places, can_edit] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the place to update + * requestBody: + * required: true + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Place' + * - $ref: '#/components/schemas/v1.PlaceWithLineage' + * properties: + * contact: + * oneOf: + * - type: string + * description: UUID of the contact + * - $ref: '#/components/schemas/NormalizedParent' + * responses: + * '200': + * description: The updated place record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Place' + * - $ref: '#/components/schemas/v1.PlaceWithLineage' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ update: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_update_places', 'can_edit'] }); const { params: { uuid }, body } = req; diff --git a/api/src/controllers/rapidpro.js b/api/src/controllers/rapidpro.js index fdd2b99fc96..2b46e7ab0c0 100644 --- a/api/src/controllers/rapidpro.js +++ b/api/src/controllers/rapidpro.js @@ -49,6 +49,93 @@ const validateRequest = req => { }; module.exports = { + /** + * @openapi + * /api/v1/sms/radpidpro/incoming-messages: + * post: + * summary: Receive incoming SMS from RapidPro + * operationId: v1SmsRadpidproIncomingMessagesPost + * deprecated: true + * description: > + * Use [POST /api/v2/sms/rapidpro/incoming-messages](#/SMS/v2SmsRapidProIncomingMessagesPost) instead. + * Webhook endpoint for receiving incoming SMS messages from the RapidPro gateway. + * Authenticated via an `Authorization: Token ` header. See the + * [documentation](/building/messaging/gateways/rapidpro/) for more details. + * tags: [SMS] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The RapidPro message id. + * from: + * type: string + * description: The sender's phone number. + * content: + * type: string + * description: The message content. + * required: [id, from, content] + * responses: + * '200': + * description: Message processing results + * content: + * application/json: + * schema: + * type: object + * properties: + * saved: + * type: number + * description: The number of messages saved. + * '400': + * $ref: '#/components/responses/BadRequest' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/sms/rapidpro/incoming-messages: + * post: + * summary: Receive incoming SMS from RapidPro + * operationId: v2SmsRapidProIncomingMessagesPost + * description: > + * Webhook endpoint for receiving incoming SMS messages from the RapidPro gateway. + * Authenticated via an `Authorization: Token ` header. See the + * [documentation](/building/messaging/gateways/rapidpro/) for more details. + * tags: [SMS] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * description: The RapidPro message id. + * from: + * type: string + * description: The sender's phone number. + * content: + * type: string + * description: The message content. + * required: [id, from, content] + * responses: + * '200': + * description: Message processing results + * content: + * application/json: + * schema: + * type: object + * properties: + * saved: + * type: number + * description: The number of messages saved. + * '400': + * $ref: '#/components/responses/BadRequest' + * '403': + * $ref: '#/components/responses/Forbidden' + */ incomingMessages: (req, res) => { return validateRequest(req) .then(() => { diff --git a/api/src/controllers/records.js b/api/src/controllers/records.js index c009cacf39d..d5052bb6238 100644 --- a/api/src/controllers/records.js +++ b/api/src/controllers/records.js @@ -32,12 +32,131 @@ const process = (req, res, options) => { .catch(err => serverUtils.error(err, req, res)); }; +/** + * @openapi + * components: + * schemas: + * RecordSuccess: + * type: object + * properties: + * success: + * type: boolean + * id: + * type: string + * description: The id of the created record. + * required: [success, id] + * requestBodies: + * RecordInput: + * required: true + * content: + * application/x-www-form-urlencoded: + * schema: + * type: object + * properties: + * message: + * type: string + * description: Message string in a supported format (e.g. Muvuku or Textforms). + * from: + * type: string + * description: Reporting phone number. + * sent_timestamp: + * type: number + * description: > + * Timestamp in ms since Unix Epoch of when the message was received on the gateway. + * Defaults to now. + * required: [message, from] + * application/json: + * schema: + * type: object + * description: > + * Form field values as properties. Special values reside in `_meta`. Only strings and + * numbers are supported as field values. All property names will be lowercased. + * properties: + * _meta: + * type: object + * properties: + * form: + * type: string + * description: The form code. + * from: + * type: string + * description: Reporting phone number. Optional. + * reported_date: + * type: number + * description: > + * Timestamp in ms since Unix Epoch of when the message was received on the + * gateway. Defaults to now. + * locale: + * type: string + * description: "Optional locale string (e.g. 'fr')." + * required: [form] + * additionalProperties: true + */ module.exports = { + /** + * @openapi + * /api/v1/records: + * post: + * summary: Create a record + * operationId: v1RecordsPost + * deprecated: true + * description: > + * Use [POST /api/v2/records](#/SMS/v2RecordsPost) instead. + * Creates a new record based on a configured form. Accepts form-encoded or JSON data. + * tags: [SMS] + * x-permissions: + * hasAll: [can_create_records] + * parameters: + * - in: query + * name: locale + * schema: + * type: string + * description: Optional locale string (e.g. `fr`). + * requestBody: + * $ref: '#/components/requestBodies/RecordInput' + * responses: + * '200': + * description: Record created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RecordSuccess' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ v1: (req, res) => { return process(req, res, { locale: req.query && req.query.locale }); }, + /** + * @openapi + * /api/v2/records: + * post: + * summary: Create a record + * operationId: v2RecordsPost + * description: > + * Creates a new record based on a configured [JSON form](/building/reference/app-settings/forms/). + * Accepts either form-encoded or JSON data. + * tags: [SMS] + * x-permissions: + * hasAll: [can_create_records] + * requestBody: + * $ref: '#/components/requestBodies/RecordInput' + * responses: + * '200': + * description: Record created successfully + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/RecordSuccess' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ // dropped support for locale because it makes no sense v2: (req, res) => { return process(req, res); diff --git a/api/src/controllers/replication-limit-log.js b/api/src/controllers/replication-limit-log.js index 3aafc017123..3329462fa30 100644 --- a/api/src/controllers/replication-limit-log.js +++ b/api/src/controllers/replication-limit-log.js @@ -3,6 +3,56 @@ const serverUtils = require('../server-utils'); const replicationLimitLog = require('../services/replication-limit-log'); module.exports = { + /** + * @openapi + * /api/v1/users-doc-count: + * get: + * summary: Get user document replication counts + * operationId: v1UsersDocCountGet + * description: > + * Returns the quantity of documents replicated by each user. Optionally filter by username. Only allowed for + * database admins. + * tags: [User] + * x-since: 3.11.0 + * parameters: + * - in: query + * name: user + * schema: + * type: string + * description: Filter by username. If not provided, returns all users. + * responses: + * '200': + * description: User replication document counts + * content: + * application/json: + * schema: + * type: object + * properties: + * limit: + * type: number + * description: The configured replication limit. + * users: + * description: > + * A single user replication log object (when filtered) or an object + * keyed by username. + * type: object + * properties: + * _id: + * type: string + * _rev: + * type: string + * user: + * type: string + * description: The username. + * date: + * type: number + * description: Timestamp of the replication count entry. + * count: + * type: number + * description: Number of documents replicated by the user. + * '401': + * $ref: '#/components/responses/Unauthorized' + */ get: (req, res) => { return auth .getUserCtx(req) diff --git a/api/src/controllers/report.js b/api/src/controllers/report.js index 5193f730ed0..e820648e90f 100644 --- a/api/src/controllers/report.js +++ b/api/src/controllers/report.js @@ -9,8 +9,50 @@ const getReportIds = ctx.bind(Report.v1.getUuidsPage); const create = ctx.bind(Report.v1.create); const update = ctx.bind(Report.v1.update); +/** + * @openapi + * tags: + * - name: Report + * description: Operations for reports + */ module.exports = { v1: { + /** + * @openapi + * /api/v1/report/{id}: + * get: + * summary: Get a report by id + * operationId: v1ReportIdGet + * description: > + * Returns a report record. Optionally includes the full contact, patient, and/or place lineage. + * tags: [Report] + * x-since: 4.18.0 + * x-permissions: + * hasAll: [can_view_reports] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the report to retrieve + * - $ref: '#/components/parameters/withLineage' + * responses: + * '200': + * description: The report record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Report' + * - $ref: '#/components/schemas/v1.ReportWithLineage' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_reports'] }); const { params: { uuid }, query: { with_lineage } } = req; @@ -23,6 +65,52 @@ module.exports = { return res.json(report); }), + /** + * @openapi + * /api/v1/report/uuid: + * get: + * summary: Get report UUIDs + * operationId: v1ReportUuidGet + * description: > + * Returns a paginated array of report identifiers matching the given freetext search term. + * tags: [Report] + * x-since: 4.18.0 + * x-permissions: + * hasAll: [can_view_reports] + * parameters: + * - in: query + * name: freetext + * required: true + * schema: + * type: string + * minLength: 3 + * description: > + * A search term for filtering reports. Must be at least 3 characters and not contain whitespace. + * - $ref: '#/components/parameters/cursor' + * - $ref: '#/components/parameters/limitId' + * responses: + * '200': + * description: A page of report UUIDs + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * description: The results for this page + * items: + * type: string + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ getUuids: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAll: ['can_view_reports'] }); const qualifier = Qualifier.byFreetext(req.query.freetext); @@ -30,12 +118,95 @@ module.exports = { return res.json(docs); }), + /** + * @openapi + * /api/v1/report: + * post: + * summary: Create a new report + * operationId: v1ReportPost + * description: Creates a new report. + * tags: [Report] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_create_records, can_edit] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.ReportInput' + * responses: + * '200': + * description: The created report record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.Report' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_create_records', 'can_edit'] }); const reportDoc = await create(req.body); return res.json(reportDoc); }), + /** + * @openapi + * /api/v1/report/{id}: + * put: + * summary: Update a report + * operationId: v1ReportIdPut + * description: > + * Updates an existing report. Fields omitted on the request will be removed from the record. + * Any included lineage data will be minified on the stored record. + * tags: [Report] + * x-since: 5.2.0 + * x-permissions: + * hasAny: [can_update_reports, can_edit] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the report to update + * requestBody: + * required: true + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Report' + * - $ref: '#/components/schemas/v1.ReportWithLineage' + * properties: + * contact: + * oneOf: + * - type: string + * description: UUID of the contact + * - $ref: '#/components/schemas/NormalizedParent' + * responses: + * '200': + * description: The updated report record + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/v1.Report' + * - $ref: '#/components/schemas/v1.ReportWithLineage' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ update: serverUtils.doOrError(async (req, res) => { await auth.assertPermissions(req, { isOnline: true, hasAny: ['can_update_reports', 'can_edit'] }); const { params: { uuid }, body } = req; diff --git a/api/src/controllers/settings.js b/api/src/controllers/settings.js index 6c1d7d83825..4f70a51f10e 100644 --- a/api/src/controllers/settings.js +++ b/api/src/controllers/settings.js @@ -5,7 +5,32 @@ const objectPath = require('object-path'); const doGet = req => auth.getUserCtx(req).then(() => settingsService.get()); +/** + * @openapi + * tags: + * - name: Config + * description: Operations for app configuration + */ module.exports = { + /** + * @openapi + * /api/v1/settings: + * get: + * summary: Get app settings + * operationId: v1SettingsGet + * description: Returns the app settings in JSON format. + * tags: [Config] + * responses: + * '200': + * description: The app settings + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + */ get: (req, res) => { return doGet(req, res) .then(settings => res.json(settings)) @@ -13,6 +38,26 @@ module.exports = { serverUtils.error(err, req, res, true); }); }, + + /** + * @openapi + * /api/v1/settings/deprecated-transitions: + * get: + * summary: Get deprecated transitions + * operationId: v1SettingsDeprecatedTransitionsGet + * description: Returns a list of deprecated transitions configured in the app settings. + * tags: [Config] + * responses: + * '200': + * description: Deprecated transitions + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + */ getDeprecatedTransitions: (req, res) => { return auth .getUserCtx(req) @@ -39,6 +84,61 @@ module.exports = { serverUtils.error(err, req, res, true); }); }, + + /** + * @openapi + * /api/v1/settings: + * put: + * summary: Update app settings + * operationId: v1SettingsPut + * description: > + * Update the app settings. By default, the provided properties are merged with existing + * settings. Use query parameters to control replacement behavior. + * tags: [Config] + * x-permissions: + * hasAll: [can_edit, can_configure] + * parameters: + * - in: query + * name: replace + * schema: + * enum: ['true', 'false'] + * default: 'false' + * description: Whether to replace existing settings for the given properties or to merge. + * - in: query + * name: overwrite + * schema: + * enum: ['true', 'false'] + * default: 'false' + * description: > + * Whether to replace the entire settings document with the input document. + * If both `replace` and `overwrite` are set, `overwrite` takes precedence. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * description: The settings properties to update. + * responses: + * '200': + * description: Settings update result + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * const: true + * updated: + * type: boolean + * description: Whether the settings document was updated. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ put: (req, res) => { return auth .check(req, ['can_edit', 'can_configure']) diff --git a/api/src/controllers/sms-gateway.js b/api/src/controllers/sms-gateway.js index 79b371b591f..7894ff2bb4a 100644 --- a/api/src/controllers/sms-gateway.js +++ b/api/src/controllers/sms-gateway.js @@ -68,9 +68,31 @@ const checkAuth = req => auth.check(req, 'can_access_gateway_api'); module.exports = { /** - * Check that the endpoint exists - * @param {Object} req The request - * @param {Object} res The response + * @openapi + * /api/sms: + * get: + * summary: Check SMS gateway connectivity + * operationId: smsGet + * description: > + * Returns a simple response to verify that the cht-gateway SMS endpoint is available. + * See the [cht-gateway documentation](https://github.com/medic/cht-gateway) for more details. + * tags: [SMS] + * x-permissions: + * hasAll: [can_access_gateway_api] + * responses: + * '200': + * description: Gateway endpoint is available + * content: + * application/json: + * schema: + * type: object + * properties: + * medic-gateway: + * type: boolean + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' */ get: (req, res) => { return checkAuth(req) @@ -78,10 +100,76 @@ module.exports = { .catch(err => serverUtils.error(err, req, res)); }, /** - * Stores new incoming messages, outgoing message status updates, - * and returns outgoing messages that are ready to be sent. - * @param {Object} req The request - * @param {Object} res The response + * @openapi + * /api/sms: + * post: + * summary: Exchange SMS messages with cht-gateway + * operationId: smsPost + * description: > + * Processes incoming messages and delivery status updates from cht-gateway, and returns + * outgoing messages that are ready to be sent. + * See the [cht-gateway documentation](https://github.com/medic/cht-gateway) for more details. + * tags: [SMS] + * x-permissions: + * hasAll: [can_access_gateway_api] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * description: Incoming messages to process. + * items: + * type: object + * properties: + * id: + * type: string + * description: The message id. + * from: + * type: string + * description: The sender's phone number. + * content: + * type: string + * description: The message content. + * required: [id, from, content] + * updates: + * type: array + * description: Delivery status updates for previously sent messages. + * items: + * type: object + * properties: + * id: + * type: string + * description: The message id. + * status: + * enum: [UNSENT, PENDING, SENT, DELIVERED, FAILED] + * description: The delivery status from the gateway. + * reason: + * type: string + * description: The reason for failure, if applicable. + * required: [id, status] + * required: [messages] + * responses: + * '200': + * description: Outgoing messages ready to be sent + * content: + * application/json: + * schema: + * type: object + * properties: + * messages: + * type: array + * description: Outgoing messages for the gateway to send. + * items: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' */ post: (req, res) => { return checkAuth(req) diff --git a/api/src/controllers/target.js b/api/src/controllers/target.js index 12c73273373..18d18ec8be5 100644 --- a/api/src/controllers/target.js +++ b/api/src/controllers/target.js @@ -24,8 +24,44 @@ const getContactIdQualifier = ({ contact_ids, contact_id }) => { return Qualifier.byContactIds(contactIds); }; +/** + * @openapi + * tags: + * - name: Target + * description: Operations for targets + */ module.exports = { v1: { + /** + * @openapi + * /api/v1/target/{id}: + * get: + * summary: Get a target by id + * operationId: v1TargetIdGet + * description: Returns a target record. + * tags: [Target] + * x-since: 5.1.0 + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the target to retrieve + * responses: + * '200': + * description: The target record + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/v1.Target' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: serverUtils.doOrError(async (req, res) => { await checkUserPermissions(req); const { id } = req.params; @@ -37,6 +73,64 @@ module.exports = { return res.json(target); }), + /** + * @openapi + * /api/v1/target: + * get: + * summary: Get targets + * operationId: v1TargetGet + * description: > + * Returns a paginated array of targets for the given contact and reporting period. Use the `cursor` returned + * in each response to retrieve subsequent pages. See also [Get Target by id](#/Target/v1TargetIdGet) for + * retrieving a single target. + * tags: [Target] + * x-since: 5.1.0 + * parameters: + * - in: query + * name: contact_id + * schema: + * type: string + * description: > + * A single contact id to filter targets by. Either `contact_id` or `contact_ids` must be + * provided. + * - in: query + * name: contact_ids + * schema: + * type: string + * description: > + * Comma-separated contact ids to filter targets by. Either `contact_id` or `contact_ids` must + * be provided. + * - in: query + * name: reporting_period + * required: true + * schema: + * type: string + * description: "The reporting period to filter targets by (e.g. '2025-09')" + * - $ref: '#/components/parameters/cursor' + * - $ref: '#/components/parameters/limitEntity' + * responses: + * '200': + * description: A page of target records + * content: + * application/json: + * schema: + * type: object + * properties: + * data: + * type: array + * description: The results for this page + * items: + * $ref: '#/components/schemas/v1.Target' + * cursor: + * $ref: '#/components/schemas/PageCursor' + * required: [data, cursor] + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ getAll: serverUtils.doOrError(async (req, res) => { await checkUserPermissions(req); @@ -48,7 +142,7 @@ module.exports = { req.query.cursor, req.query.limit ); - + return res.json(docs); }), }, diff --git a/api/src/controllers/upgrade.js b/api/src/controllers/upgrade.js index 1d4e14ba4a1..1d1790e4207 100644 --- a/api/src/controllers/upgrade.js +++ b/api/src/controllers/upgrade.js @@ -8,6 +8,34 @@ const configWatcher = require('../services/config-watcher'); const REQUIRED_PERMISSIONS = ['can_upgrade']; const checkAuth = (req) => auth.check(req, REQUIRED_PERMISSIONS); +/** + * @openapi + * tags: + * - name: Upgrade + * description: Operations for upgrading the CHT instance + * components: + * schemas: + * UpgradeRequestBody: + * type: object + * required: [build] + * properties: + * build: + * type: object + * required: [namespace, application, version] + * properties: + * namespace: + * type: string + * description: Must be "medic". + * application: + * type: string + * description: Must be "medic". + * version: + * type: string + * description: > + * The version to upgrade to. Should correspond to a release, pre-release, or branch + * that has been pushed to the builds server. + */ + const upgrade = (req, res, stageOnly) => { return checkAuth(req) .then(userCtx => { @@ -86,17 +114,339 @@ const compareUpgrade = async (req, res) => { }; module.exports = { + /** + * @openapi + * /api/v2/upgrade/can-upgrade: + * get: + * summary: Check if an upgrade can be performed + * operationId: v2UpgradeCanUpgradeGet + * description: Returns whether the instance is in a state where an upgrade can be performed. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: Whether an upgrade can be performed + * content: + * application/json: + * schema: + * type: object + * properties: + * ok: + * type: boolean + * description: Whether an upgrade can be performed. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ canUpgrade: canUpgrade, + + /** + * @openapi + * /api/v1/upgrade: + * post: + * summary: Upgrade to a version + * operationId: v1UpgradePost + * deprecated: true + * description: > + * Use [POST /api/v2/upgrade](#/Upgrade/v2UpgradePost) instead. + * Performs a complete upgrade to the provided version. This is equivalent to calling + * `/api/v1/upgrade/stage` and then `/api/v1/upgrade/complete` once staging has finished. + * This is asynchronous. Progress can be followed by watching the `horti-upgrade` document. + * Calling this endpoint will eventually cause api and sentinel to restart. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpgradeRequestBody' + * responses: + * '200': + * description: Upgrade initiated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/upgrade: + * post: + * summary: Upgrade to a version + * operationId: v2UpgradePost + * description: > + * Performs a complete upgrade to the provided version. This is equivalent to calling + * `/api/v2/upgrade/stage` and then `/api/v2/upgrade/complete` once staging has finished. + * This is asynchronous. Progress can be followed via [GET /api/v2/upgrade](#/Upgrade/v2UpgradeGet). + * Calling this endpoint will eventually cause api and sentinel to restart. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpgradeRequestBody' + * responses: + * '200': + * description: Upgrade initiated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ upgrade: (req, res) => upgrade(req, res, false), + + /** + * @openapi + * /api/v1/upgrade/stage: + * post: + * summary: Stage an upgrade + * operationId: v1UpgradeStagePost + * deprecated: true + * description: > + * Use [POST /api/v2/upgrade/stage](#/Upgrade/v2UpgradeStagePost) instead. + * Stages an upgrade to the provided version. Does as much of the upgrade as possible without actually + * swapping versions over and restarting. An upgrade has been staged when the `horti-upgrade` document + * has `"action": "stage"` and `"staging_complete": true`. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpgradeRequestBody' + * responses: + * '200': + * description: Staging initiated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * /api/v2/upgrade/stage: + * post: + * summary: Stage an upgrade + * operationId: v2UpgradeStagePost + * description: > + * Stages an upgrade to the provided version. Does as much of the upgrade as possible without actually + * swapping versions over and restarting. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpgradeRequestBody' + * responses: + * '200': + * description: Staging initiated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ stage: (req, res) => upgrade(req, res, true), + + /** + * @openapi + * /api/v1/upgrade/complete: + * post: + * summary: Complete a staged upgrade + * operationId: v1UpgradeCompletePost + * deprecated: true + * description: > + * Use [POST /api/v2/upgrade/complete](#/Upgrade/v2UpgradeCompletePost) instead. + * Completes a staged upgrade. Returns a 404 if there is no upgrade in the staged position. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: Upgrade completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + * /api/v2/upgrade/complete: + * post: + * summary: Complete a staged upgrade + * operationId: v2UpgradeCompletePost + * description: > + * Completes a staged upgrade. Returns a 404 if there is no upgrade in the staged position. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: Upgrade completed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ complete: completeUpgrade, + + /** + * @openapi + * /api/v2/upgrade: + * get: + * summary: Get upgrade status + * operationId: v2UpgradeGet + * description: Returns the current upgrade status, indexer progress, and builds server URL. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: The current upgrade status + * content: + * application/json: + * schema: + * type: object + * properties: + * upgradeDoc: + * description: The current upgrade document, or null if no upgrade is in progress. + * indexers: + * description: Current indexer progress information. + * buildsUrl: + * type: string + * description: The URL of the builds server. + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ upgradeInProgress: upgradeInProgress, + + /** + * @openapi + * /api/v2/upgrade: + * delete: + * summary: Abort an upgrade + * operationId: v2UpgradeDelete + * description: Aborts an in-progress upgrade. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: Upgrade aborted + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ abort: abortUpgrade, + + /** + * @openapi + * /api/v2/upgrade/service-worker: + * post: + * summary: Update the service worker + * operationId: v2UpgradeServiceWorkerPost + * description: Triggers an update of the service worker cache. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * responses: + * '200': + * description: Service worker updated + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/OkResponse' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ serviceWorker: (req, res) => { return checkAuth(req) .then(() => configWatcher.updateServiceWorker()) .then(() => res.json({ ok: true })) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v2/upgrade/compare: + * post: + * summary: Compare build versions + * operationId: v2UpgradeComparePost + * description: > + * Compares the provided build version against the currently deployed version, returning + * differences in design documents. + * tags: [Upgrade] + * x-permissions: + * hasAll: [can_upgrade] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UpgradeRequestBody' + * responses: + * '200': + * description: Comparison results + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * additionalProperties: true + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ compare: compareUpgrade, }; diff --git a/api/src/controllers/users.js b/api/src/controllers/users.js index 25a0cb4fc03..f10659c2eba 100644 --- a/api/src/controllers/users.js +++ b/api/src/controllers/users.js @@ -172,13 +172,205 @@ const verifyUpdateRequest = async (req) => { return { fullPermission }; }; +/** + * @openapi + * tags: + * - name: User + * description: Operations for user management + * components: + * schemas: + * UserInput: + * type: object + * required: [username, roles] + * properties: + * username: + * type: string + * description: Identifier used for authentication. + * roles: + * type: array + * items: + * type: string + * place: + * description: > + * Place identifier or object representing the place the user resides in. Required if roles contain an + * offline role. + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * contact: + * description: > + * Person identifier or object representing the person contact for the user. Required if roles + * contain an offline role. + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * password: + * type: string + * description: > + * Password for authentication. Must be at least 8 characters long and difficult to guess. Required if + * `token_login` or `oidc_username` is not set. + * phone: + * type: string + * description: Valid phone number. Required if `token_login` is set. + * token_login: + * type: boolean + * description: > + * Sets [login by SMS](/building/reference/app-settings/token_login) for this user. Added in `3.10.0`. + * fullname: + * type: string + * email: + * type: string + * known: + * type: boolean + * description: Indicates if the user has logged in before. + * password_change_required: + * type: boolean + * description: > + * Set `false` to skip [password reset prompt](/building/login/#password-reset-on-first-login) on next + * login. Added in `4.17.0`. + * oidc_username: + * type: string + * description: > + * Unique username for [authenticating via OIDC](/building/reference/api/#login-by-oidc). This + * value must match the `email` claim returned for the user by the OIDC provider. Added in `4.20.0`. + * UserCreateResult: + * type: object + * properties: + * contact: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * user-settings: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * user: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * InvalidUserInputError: + * type: object + * additionalProperties: true + * properties: + * error: + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * examples: + * - error: + * message: 'Username "mary" already taken.' + * translationKey: username.taken + * translationParams: + * username: mary + * - error: 'Missing required fields: username, password, type or roles' + * details: + * failingIndexes: + * - fields: [username, password, 'type or roles'] + * index: 0 + * - fields: [password, 'type or roles'] + * index: 1 + */ module.exports = { + /** + * @openapi + * /api/v1/users: + * get: + * summary: List users + * operationId: v1UsersGet + * deprecated: true + * description: > + * Use [GET /api/v2/users](#/User/v2UsersGet) instead. + * Returns a list of users and their profile data. + * tags: [User] + * x-permissions: + * hasAll: [can_view_users] + * responses: + * '200': + * description: A list of users + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ list: (req, res) => { return getUserList(req) .then(list => convertUserListToV1(list)) .then(body => res.json(body)) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v1/users: + * post: + * summary: Create users + * operationId: v1UsersPost + * deprecated: true + * description: | + * Use [POST /api/v3/users](#/User/v3UsersPost) to create a single user or + * [POST /api/v2/users](#/User/v2UsersPost) to create multiple users. + * Create new users with a place and a contact. Accepts a single user object or an array of user objects + * (since CHT `3.15.0`). Users are created in parallel and the creation is not aborted even if one of the + * users fails to be created. + * + * Passing a single user in the request’s body will return a single object whereas passing an array of users + * will return an array of objects. + * tags: [User] + * x-permissions: + * hasAll: [can_edit, can_create_users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/UserInput' + * - type: array + * items: + * $ref: '#/components/schemas/UserInput' + * responses: + * '200': + * description: User creation results + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/UserCreateResult' + * - type: array + * items: + * oneOf: + * - $ref: '#/components/schemas/UserCreateResult' + * - $ref: '#/components/schemas/InvalidUserInputError' + * '400': + * description: Invalid user data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InvalidUserInputError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: (req, res) => { return auth .check(req, ['can_edit', 'can_create_users']) @@ -186,6 +378,72 @@ module.exports = { .then(body => res.json(body)) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v1/users/{username}: + * post: + * summary: Update a user + * operationId: v1UsersUsernamePost + * deprecated: true + * description: > + * Use [POST /api/v3/users/{username}](#/User/v3UsersUsernamePost) instead. + * Update property values on a user account. Users with `can_edit` and `can_update_users` + * permissions can update any user. Users can update themselves without these permissions, + * but cannot modify `type`, `roles`, `contact`, or `place`. Password changes require + * Basic Auth (either the header or in the URL). This is to ensure the password is known at + * time of request, and no one is hijacking a cookie. + * tags: [User] + * x-permissions: + * hasAll: [can_edit, can_update_users] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: The username of the user to update. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * description: User properties to update. + * responses: + * '200': + * description: User updated + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * user-settings: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * '400': + * description: Invalid user data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InvalidUserInputError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ update: async (req, res) => { if (_.isEmpty(req.body)) { return serverUtils.emptyJSONBodyError(req, res); @@ -209,6 +467,37 @@ module.exports = { serverUtils.error(err, req, res); } }, + + /** + * @openapi + * /api/v1/users/{username}: + * delete: + * summary: Delete a user + * operationId: v1UsersUsernameDelete + * description: Delete a user. Does not affect the person or place associated with the user. + * tags: [User] + * x-permissions: + * hasAll: [can_edit, can_delete_users] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: The username of the user to delete. + * responses: + * '200': + * description: User deleted + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ delete: (req, res) => { auth .check(req, ['can_edit', 'can_delete_users']) @@ -216,6 +505,67 @@ module.exports = { .then(result => res.json(result)) .catch(err => serverUtils.error(err, req, res)); }, + + /** + * @openapi + * /api/v1/users-info: + * get: + * summary: Get user replication info + * operationId: v1UsersInfoGet + * description: | + * Returns the total number of documents an offline user would replicate (`total_docs`), the number of + * docs excluding tasks the user would replicate (`warn_docs`), and a warning flag (`warn`) if the count + * exceeds the recommended limit (`10,000`). + * + * Offline users can get their own doc count. Online users with `can_update_users` permission + * can query for any user by providing `role` and `facility_id` query parameters. (When requested as an + * online user, the number of tasks are never counted and never returned, so `warn_docs` is always equal to + * `total_docs`.) + * tags: [User] + * parameters: + * - in: query + * name: facility_id + * schema: + * type: string + * description: UUID of the user's facility. Required for online users. + * - in: query + * name: role + * schema: + * type: string + * description: > + * User role (must be an offline role). Accepts a string or JSON array. Required for online users. + * - in: query + * name: contact_id + * schema: + * type: string + * description: UUID of the user's associated contact. Optional for online users. + * responses: + * '200': + * description: User replication info + * content: + * application/json: + * schema: + * type: object + * properties: + * total_docs: + * type: number + * description: Total number of documents the user would replicate. + * warn_docs: + * type: number + * description: Number of docs excluding tasks the user would replicate. + * warn: + * type: boolean + * description: Whether the doc count exceeds the recommended limit. + * limit: + * type: number + * description: The recommended document limit. + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ info: (req, res) => { let userCtx; try { @@ -229,6 +579,62 @@ module.exports = { }, v2: { + /** + * @openapi + * /api/v2/users/{username}: + * get: + * summary: Get a user by username. + * operationId: v2UsersUsernameGet + * description: > + * Returns a user's profile data. Users with `can_view_users` permission can view any + * user. Users can also view their own profile. + * tags: [User] + * x-since: 4.7.0 + * x-permissions: + * hasAll: [can_view_users] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: The username to retrieve. + * responses: + * '200': + * description: The user profile + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * example: + * id: org.couchdb.user:demo + * rev: 14-8758c8493edcc6dac50366173fc3e24a + * type: district-manager + * fullname: Example User + * username: demo + * oidc_username: demo@email.com + * place: + * _id: eeb17d6d-5dde-c2c0-62c4a1a0ca17d38b + * type: district_hospital + * name: Sample District + * contact: + * _id: eeb17d6d-5dde-c2c0-62c4a1a0ca17fd17 + * type: person + * name: Paul + * phone: '+2868917046' + * contact: + * _id: eeb17d6d-5dde-c2c0-62c4a1a0ca17fd17 + * type: person + * name: Paul + * phone: '+2868917046' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + * '404': + * $ref: '#/components/responses/NotFound' + */ get: async (req, res) => { try { const userCtx = await auth.getUserCtx(req); @@ -243,6 +649,44 @@ module.exports = { serverUtils.error(err, req, res); } }, + + /** + * @openapi + * /api/v2/users: + * get: + * summary: List users + * operationId: v2UsersGet + * description: Returns a list of users and their profile data including roles. + * tags: [User] + * x-since: 4.1.0 + * x-permissions: + * hasAll: [can_view_users] + * parameters: + * - in: query + * name: facility_id + * schema: + * type: string + * description: Filter by facility UUID. Added in `4.7.0`. + * - in: query + * name: contact_id + * schema: + * type: string + * description: Filter by associated contact UUID. Added in `4.7.0`. + * responses: + * '200': + * description: A list of users + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ list: async (req, res) => { try { const body = await getUserList(req); @@ -251,6 +695,69 @@ module.exports = { serverUtils.error(err, req, res); } }, + + /** + * @openapi + * /api/v2/users: + * post: + * summary: Create users - bulk import (JSON or CSV) + * operationId: v2UsersPost + * description: | + * Create new users with a place and a contact. Accepts either a JSON array of user objects or a CSV file + * where each row represents a user. A log entry is created for each bulk import in the `medic-logs` database. + * + * ### Example + * + * A spreadsheet compatible with the default configuration of the CHT is available. + * [Click here](https://docs.google.com/spreadsheets/d/1yUenFP-5deQ0I9c-OYDTpbKYrkl3juv9djXoLLPoQ7Y/copy) to + * make a copy of the spreadsheet in Google Sheets. [A guide](/building/training/users-bulk-load) on how to + * import users with this spreadsheet from within the Admin Console (without manually calling this endpoint) + * is available. + * + * ### Logging + * + * A log entry is created with each bulk import that contains the import status for each user and the import + * progress status that gets updated throughout the import and finalized upon completion. These entries are + * saved in the `medic-logs` database and you can access them by querying documents with a key that starts + * with `bulk-user-upload-`. + * tags: [User] + * x-since: 3.16.0 + * x-permissions: + * hasAll: [can_edit, can_create_users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * oneOf: + * - $ref: '#/components/schemas/UserInput' + * - type: array + * items: + * $ref: '#/components/schemas/UserInput' + * text/csv: + * schema: + * type: string + * description: > + * CSV with headers matching user properties. Columns with `:excluded` suffix + * are ignored. + * responses: + * '200': + * description: User creation results + * content: + * application/json: + * schema: + * type: array + * items: + * oneOf: + * - $ref: '#/components/schemas/UserCreateResult' + * - $ref: '#/components/schemas/InvalidUserInputError' + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: async (req, res) => { try { await auth.check(req, ['can_edit', 'can_create_users']); @@ -279,6 +786,41 @@ module.exports = { }, }, v3: { + + /** + * @openapi + * /api/v3/users: + * post: + * summary: Create a user + * operationId: v3UsersPost + * description: Creates a user that can be associated with multiple facilities. + * tags: [User] + * x-permissions: + * hasAll: [can_edit, can_create_users] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserInput' + * responses: + * '200': + * description: User creation result + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UserCreateResult' + * '400': + * description: Invalid user data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InvalidUserInputError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ create: async (req, res) => { try { await auth.check(req, ['can_edit', 'can_create_users']); @@ -289,6 +831,69 @@ module.exports = { serverUtils.error(error, req, res); } }, + /** + * @openapi + * /api/v3/users/{username}: + * post: + * summary: Update a user + * operationId: v3UsersUsernamePost + * description: > + * Update property values on a user account. Users with `can_edit` and `can_update_users` + * permissions can update any user. Users can update themselves without these permissions, + * but cannot modify `type`, `roles`, `contact`, or `place`. Password changes require + * Basic Auth (either the header or in the URL). This is to ensure the password is known at + * time of request, and no one is hijacking a cookie. + * tags: [User] + * x-permissions: + * hasAll: [can_edit, can_update_users] + * parameters: + * - in: path + * name: username + * required: true + * schema: + * type: string + * description: The username of the user to update. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * description: User properties to update. + * responses: + * '200': + * description: User updated + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * user-settings: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * '400': + * description: Invalid user data + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/InvalidUserInputError' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ update: (req, res) => { return module.exports.update(req, res); }, diff --git a/api/src/routing.js b/api/src/routing.js index 073d4201c3c..5f288726735 100644 --- a/api/src/routing.js +++ b/api/src/routing.js @@ -394,11 +394,55 @@ app.all('/setup/finish', function(req, res) { res.status(200).send('Setup services are not currently available'); }); +/** + * @openapi + * /api/info: + * get: + * summary: Get the version of the CHT server + * operationId: apiInfoGet + * description: Returns the version of the CHT server. + * tags: [Monitoring] + * responses: + * '200': + * description: The API info data + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * type: string + * required: [version] + */ app.get('/api/info', function(req, res) { const p = require('../package.json'); res.json({ version: p.version }); }); +/** + * @openapi + * /api/deploy-info: + * get: + * summary: Get deploy information + * operationId: apiDeployInfoGet + * description: Returns build and deploy information for the running CHT instance. + * tags: [Monitoring] + * responses: + * '200': + * description: The deploy info data + * content: + * application/json: + * schema: + * type: object + * properties: + * version: + * type: string + * description: The version of the deployed CHT instance + * required: [version] + * additionalProperties: true + * '401': + * $ref: '#/components/responses/Unauthorized' + */ app.get('/api/deploy-info', async (req, res) => { if (!req.userCtx) { return serverUtils.notLoggedIn(req, res); @@ -461,6 +505,66 @@ app.postJson('/api/v3/users/:username', users.v3.update); app.delete('/api/v1/users/:username', users.delete); app.get('/api/v1/users-info', authorization.handleAuthErrors, authorization.getUserSettings, users.info); +/** + * @openapi + * /api/v1/places: + * post: + * summary: Create a place + * operationId: v1PlacesPost + * deprecated: true + * description: > + * Use [POST /api/v1/place](#/Place/v1PlacePost) instead. + * Create a new place and optionally a contact. The parent can be referenced by UUID or + * created inline. A contact can also be created inline or referenced by UUID. + * tags: [Place] + * x-permissions: + * hasAll: [can_edit, can_create_places] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: The name of the place. + * type: + * type: string + * description: "The place type (e.g. `clinic`, `health_center`, `district_hospital`)." + * parent: + * description: Parent place UUID or inline object. Required unless creating a top-level place. + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * contact: + * description: Contact person UUID or inline object. + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * required: [name, type] + * additionalProperties: true + * responses: + * '200': + * description: Place created + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ app.postJson('/api/v1/places', function(req, res) { auth .check(req, ['can_edit', 'can_create_places']) @@ -473,6 +577,53 @@ app.postJson('/api/v1/places', function(req, res) { .catch(err => serverUtils.error(err, req, res)); }); +/** + * @openapi + * /api/v1/places/{id}: + * post: + * summary: Update a place + * operationId: v1PlacesIdPost + * deprecated: true + * description: > + * Use [PUT /api/v1/place/{id}](#/Place/v1PlaceIdPut) instead. + * Update a place and optionally its contact. + * tags: [Place] + * x-permissions: + * hasAll: [can_edit, can_update_places] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: The id of the place to update. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * additionalProperties: true + * description: Place properties to update. + * responses: + * '200': + * description: Place updated + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ app.postJson('/api/v1/places/:id', function(req, res) { auth .check(req, ['can_edit', 'can_update_places']) @@ -492,6 +643,64 @@ app.get('/api/v1/place/:uuid', place.v1.get); app.postJson('/api/v1/place', place.v1.create); app.putJson('/api/v1/place/:uuid', place.v1.update); +/** + * @openapi + * /api/v1/people: + * post: + * summary: Create a person + * operationId: v1PeoplePost + * deprecated: true + * description: > + * Use [POST /api/v1/person](#/Person/v1PersonPost) instead. + * Create a new person contact. A place can be created inline or referenced by UUID. + * tags: [Person] + * x-permissions: + * hasAll: [can_edit, can_create_people] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * name: + * type: string + * description: The person's name. + * type: + * type: string + * default: person + * description: > + * ID of the contact_type for the new person. Defaults to `person` for backwards compatibility. + * place: + * description: Place UUID or inline object. + * oneOf: + * - type: string + * - type: object + * additionalProperties: true + * reported_date: + * type: number + * description: Timestamp of when the record was reported or created. Defaults to now. + * required: [name] + * additionalProperties: true + * responses: + * '200': + * description: Person created + * content: + * application/json: + * schema: + * type: object + * properties: + * id: + * type: string + * rev: + * type: string + * '400': + * $ref: '#/components/responses/BadRequest' + * '401': + * $ref: '#/components/responses/Unauthorized' + * '403': + * $ref: '#/components/responses/Forbidden' + */ app.postJson('/api/v1/people', function(req, res) { auth .check(req, ['can_edit', 'can_create_people']) diff --git a/api/src/services/authorization.js b/api/src/services/authorization.js index ae20b775f22..2e6c5f59e38 100644 --- a/api/src/services/authorization.js +++ b/api/src/services/authorization.js @@ -19,14 +19,13 @@ const DEFAULT_DDOCS = [ ]; /** - * @typedef {{ - * name:string, - * roles:string[] - * contact_id:string, - * facility_id: string[], - * contact:Object, - * facility:Object[] - * }} userCtx + * @typedef {Object} userCtx + * @property {string} name + * @property {Array.} roles + * @property {string} contact_id + * @property {Array.} facility_id + * @property {Object} contact + * @property {Array.} facility */ /** @@ -36,19 +35,18 @@ const DEFAULT_DDOCS = [ * @property {string[]} subjectIds, * @property {number} contactDepth. * @property {number} reportDepth. - * @property {{[docId:string]:number}} subjectsDepth, + * @property {Object.} subjectsDepth, * @property {boolean} replicatePrimaryContacts, */ /** - * @typedef {{ - * key: string|string[], - * type?: string, - * submitter?: string, - * subject?: string, - * private?: string, - * needed_signoff?: string, - * }} DocByReplicationKey + * @typedef {Object} DocByReplicationKey + * @property {string|Array.} key + * @property {string} [type] + * @property {string} [submitter] + * @property {string} [subject] + * @property {string} [private] + * @property {string} [needed_signoff] */ // fake view map, to store whether doc is a medic.user-settings doc @@ -210,7 +208,7 @@ const allowedDoc = (docId, authorizationContext, { docsByReplicationKey, contact /** * Returns whether an authenticated user has access to a document * @param {String} docId document id - * @param {Array<{ key: [string, string?], value: { _id:string, shortcode:string} }>} docContactsByDepth + * @param {Array.<{ key: Array., value: {_id: string, shortcode: string} }>} docContactsByDepth * @param {AuthorizationContext} authorizationContext * @param {Boolean} authorizationContext.replicatePrimaryContacts - whether to allow replication of primary contacts * @@ -384,7 +382,8 @@ const getAuthorizationContext = async (userCtx) => { * Retrieves unknown primary contacts from the database. * Iterates over all primary contacts and includes them in the subjects lists and assigns correct depth. * @param {AuthorizationContext} authCtx - * @param {{[docId:string]: { primaryContact:string, subjects:string[] }}} contacts - map of contacts and their subject + * @param {Object.}>} contacts - map of contacts and their + * subject * ids and primary contact * @returns {Promise} */ @@ -462,7 +461,7 @@ const findContactsByReplicationKeys = (replicationKeys) => { /** * Returns a list of places for which the passed contacts are assigned as primary contacts. - * @param {{ _id:string }[]} docs + * @param {Array.<{_id: string}>} docs * @returns {Promise} */ const getPrimaryPlaces = async (docs) => { @@ -508,7 +507,7 @@ const populateAllowedSubjectIds = (authorizationCtx, contacts) => { * relevant allowed subject ids. * * @param {userCtx} userCtx - * @param {{ doc:{}, viewResults:{} }[]} scopeDocsCtx + * @param {Array.<{doc: Object, viewResults: Object}>} scopeDocsCtx * @returns { Promise } */ const getScopedAuthorizationContext = async (userCtx, scopeDocsCtx = []) => { @@ -618,7 +617,7 @@ const sortedIncludes = (sortedArray, element) => _.sortedIndexOf(sortedArray, St /** * Returns a list of document ids that the user is allowed to see and edit * @param authorizationContext - * @returns {Promise<{ id: string, fields: DocByReplicationKey }[]>} + * @returns {Promise.>} */ const getDocsByReplicationKeyNouveau = async (authorizationContext) => { const allKeys = [...authorizationContext.subjectIds]; @@ -653,7 +652,7 @@ const getDocsByReplicationKeyNouveau = async (authorizationContext) => { /** * Returns a list of document ids that the user is allowed to see and edit * @param {AuthorizationContext} authorizationContext - * @returns {Promise<{ id: string, fields: DocByReplicationKey }[]>} + * @returns {Promise.>} */ const getDocsByReplicationKey = async (authorizationContext) => { return getDocsByReplicationKeyNouveau(authorizationContext).then(hits => { @@ -684,7 +683,7 @@ const getDocsByReplicationKey = async (authorizationContext) => { /** * Returns a list of document ids that the user is allowed to see and edit * @param {AuthorizationContext} authCtx - * @param {{ id: string, fields: DocByReplicationKey }[]} docsByReplicationKey + * @param {Array.<{id: string, fields: DocByReplicationKey}>} docsByReplicationKey * @param {boolean} includeTasks - whether task documents should be included * @returns {string[]} */ @@ -708,7 +707,7 @@ const filterAllowedDocIds = (authCtx, docsByReplicationKey, { includeTasks = tru * Evaluates medic/contacts_by_depth and medic/docs_by_replication_key view map functions over the document and * returns results, and whether the document is a user-settings document or not * @param {Object} doc - CouchDb document - * @returns {{contactsByDepth: [], docsByReplicationKey: [], couchDbUser: boolean}} + * @returns {{contactsByDepth: Array, docsByReplicationKey: Array, couchDbUser: boolean}} */ const getViewResults = (doc) => { const docsByReplicationKey = viewMapUtils.getNouveauViewMapFn('medic', 'docs_by_replication_key')(doc) || {}; diff --git a/package-lock.json b/package-lock.json index 099b8f1adad..845d35f5cb1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -80,6 +80,9 @@ "@medic/translation-checker": "^1.1.0", "@octokit/core": "^5.2.0", "@octokit/plugin-paginate-graphql": "^4.0.1", + "@stoplight/spectral-core": "^1.21.0", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-rulesets": "^1.22.0", "@stylistic/eslint-plugin": "^5.1.0", "@tsconfig/node22": "^22.0.2", "@types/chai": "^4.3.6", @@ -169,7 +172,9 @@ "shellcheck": "^4.1.0", "sinon": "^21.0.1", "sinon-chai": "^3.7.0", + "swagger-jsdoc": "^6.2.8", "tail": "^2.2.6", + "ts-json-schema-generator": "^2.9.0", "ts-node": "^10.9.2", "typedoc": "^0.28.7", "typescript": "5.7.3", @@ -455,6 +460,74 @@ "ajv": ">=8" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@apidevtools/json-schema-ref-parser/node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@appium/base-driver": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/@appium/base-driver/-/base-driver-10.2.0.tgz", @@ -1304,6 +1377,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@asyncapi/specs": { + "version": "6.11.1", + "resolved": "https://registry.npmjs.org/@asyncapi/specs/-/specs-6.11.1.tgz", + "integrity": "sha512-A3WBLqAKGoJ2+6FWFtpjBlCQ1oFCcs4GxF7zsIGvNqp/klGUHjlA3aAcZ9XMMpLGE8zPeYDz2x9FmO6DSuKraQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.11" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -5553,6 +5636,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@jsdoc/salty": { "version": "0.2.8", "resolved": "https://registry.npmjs.org/@jsdoc/salty/-/salty-0.2.8.tgz", @@ -5565,6 +5655,45 @@ "node": ">=v12.0.0" } }, + "node_modules/@jsep-plugin/assignment": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/regex": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, + "node_modules/@jsep-plugin/ternary": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/ternary/-/ternary-1.1.4.tgz", + "integrity": "sha512-ck5wiqIbqdMX6WRQztBL7ASDty9YLgJ3sSAK5ZpBzXeySvFGCzIvM6UiAI4hTZ22fEcYQVV/zhUbNscggW+Ukg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + }, + "peerDependencies": { + "jsep": "^0.4.0||^1.0.0" + } + }, "node_modules/@mdn/browser-compat-data": { "version": "5.7.6", "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-5.7.6.tgz", @@ -6497,6 +6626,358 @@ "dev": true, "license": "MIT" }, + "node_modules/@stoplight/better-ajv-errors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@stoplight/better-ajv-errors/-/better-ajv-errors-1.0.3.tgz", + "integrity": "sha512-0p9uXkuB22qGdNfy3VeEhxkU5uwvp/KrBTAbrLBURv6ilxIVwanKwjMc41lQfIVgPGcOkmLbTolfFrSsueu7zA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jsonpointer": "^5.0.0", + "leven": "^3.1.0" + }, + "engines": { + "node": "^12.20 || >= 14.13" + }, + "peerDependencies": { + "ajv": ">=8" + } + }, + "node_modules/@stoplight/json": { + "version": "3.21.7", + "resolved": "https://registry.npmjs.org/@stoplight/json/-/json-3.21.7.tgz", + "integrity": "sha512-xcJXgKFqv/uCEgtGlPxy3tPA+4I+ZI4vAuMJ885+ThkTHFVkC+0Fm58lA9NlsyjnkpxFh4YiQWpH+KefHdbA0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.3", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "jsonc-parser": "~2.2.1", + "lodash": "^4.17.21", + "safe-stable-stringify": "^1.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-readers/-/json-ref-readers-1.2.2.tgz", + "integrity": "sha512-nty0tHUq2f1IKuFYsLM4CXLZGHdMn+X/IwEUIpeSOXt0QjMUbL0Em57iJUDzz+2MkWG83smIigNZ3fauGjqgdQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "node-fetch": "^2.6.0", + "tslib": "^1.14.1" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json-ref-readers/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, + "node_modules/@stoplight/json-ref-resolver": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@stoplight/json-ref-resolver/-/json-ref-resolver-3.1.6.tgz", + "integrity": "sha512-YNcWv3R3n3U6iQYBsFOiWSuRGE5su1tJSiX6pAPRVk7dP0L7lqCteXGzuVRQ0gMZqUl8v1P0+fAKxF6PLo9B5A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.21.0", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^12.3.0 || ^13.0.0", + "@types/urijs": "^1.19.19", + "dependency-graph": "~0.11.0", + "fast-memoize": "^2.5.2", + "immer": "^9.0.6", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "urijs": "^1.19.11" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/@stoplight/json/node_modules/jsonc-parser": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-2.2.1.tgz", + "integrity": "sha512-o6/yDBYccGvTz1+QFevz6l6OBZ2+fMVu2JZ9CIhzsYRX4mjaK5IyX9eldUdCmga16zlgQxyrj5pt9kzuj2C02w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/json/node_modules/safe-stable-stringify": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-1.1.1.tgz", + "integrity": "sha512-ERq4hUjKDbJfE4+XtZLFPCDi8Vb1JqaxAPTxWFLBx8XcAlf9Bda/ZJdVezs/NAfsMQScyIlUMx+Yeu7P7rx5jw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stoplight/ordered-object-literal": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/ordered-object-literal/-/ordered-object-literal-1.0.5.tgz", + "integrity": "sha512-COTiuCU5bgMUtbIFBuyyh2/yVVzlr5Om0v5utQDgBCuQUOPgU1DwoffkTfg4UBQOvByi5foF4w4T+H9CoRe5wg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/path": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@stoplight/path/-/path-1.3.2.tgz", + "integrity": "sha512-lyIc6JUlUA8Ve5ELywPC8I2Sdnh1zc1zmbYgVarhXIp9YeAB0ReeqmGEOWNtlHkbP2DAA1AL65Wfn2ncjK/jtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/@stoplight/spectral-core": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-core/-/spectral-core-1.21.0.tgz", + "integrity": "sha512-oj4e/FrDLUhBRocIW+lRMKlJ/q/rDZw61HkLbTFsdMd+f/FTkli2xHNB1YC6n1mrMKjjvy7XlUuFkC7XxtgbWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "~3.21.0", + "@stoplight/path": "1.3.2", + "@stoplight/spectral-parsers": "^1.0.0", + "@stoplight/spectral-ref-resolver": "^1.0.4", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "~13.6.0", + "@types/es-aggregate-error": "^1.0.2", + "@types/json-schema": "^7.0.11", + "ajv": "^8.17.1", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "es-aggregate-error": "^1.0.7", + "jsonpath-plus": "^10.3.0", + "lodash": "~4.17.23", + "lodash.topath": "^4.5.2", + "minimatch": "3.1.2", + "nimma": "0.2.3", + "pony-cause": "^1.1.1", + "simple-eval": "1.0.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@stoplight/spectral-core/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@stoplight/spectral-formats": { + "version": "1.8.2", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-formats/-/spectral-formats-1.8.2.tgz", + "integrity": "sha512-c06HB+rOKfe7tuxg0IdKDEA5XnjL2vrn/m/OVIIxtINtBzphZrOgtRn7epQ5bQF5SWp84Ue7UJWaGgDwVngMFw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.19.2", + "@types/json-schema": "^7.0.7", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-functions": { + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-functions/-/spectral-functions-1.10.1.tgz", + "integrity": "sha512-obu8ZfoHxELOapfGsCJixKZXZcffjg+lSoNuttpmUFuDzVLT3VmH8QkPXfOGOL5Pz80BR35ClNAToDkdnYIURg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.1", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-runtime": "^1.1.2", + "ajv": "^8.17.1", + "ajv-draft-04": "~1.0.0", + "ajv-errors": "~3.0.0", + "ajv-formats": "~2.1.1", + "lodash": "~4.17.21", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-parsers/-/spectral-parsers-1.0.5.tgz", + "integrity": "sha512-ANDTp2IHWGvsQDAY85/jQi9ZrF4mRrA5bciNHX+PUxPr4DwS6iv4h+FVWJMVwcEYdpyoIdyL+SRmHdJfQEPmwQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "~3.21.0", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml": "~4.3.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-parsers/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/spectral-ref-resolver": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-ref-resolver/-/spectral-ref-resolver-1.0.5.tgz", + "integrity": "sha512-gj3TieX5a9zMW29z3mBlAtDOCgN3GEc1VgZnCVlr5irmR4Qi5LuECuFItAq4pTn5Zu+sW5bqutsCH7D4PkpyAA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json-ref-readers": "1.2.2", + "@stoplight/json-ref-resolver": "~3.1.6", + "@stoplight/spectral-runtime": "^1.1.2", + "dependency-graph": "0.11.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-rulesets": { + "version": "1.22.0", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-rulesets/-/spectral-rulesets-1.22.0.tgz", + "integrity": "sha512-l2EY2jiKKLsvnPfGy+pXC0LeGsbJzcQP5G/AojHgf+cwN//VYxW1Wvv4WKFx/CLmLxc42mJYF2juwWofjWYNIQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@asyncapi/specs": "^6.8.0", + "@stoplight/better-ajv-errors": "1.0.3", + "@stoplight/json": "^3.17.0", + "@stoplight/spectral-core": "^1.19.4", + "@stoplight/spectral-formats": "^1.8.1", + "@stoplight/spectral-functions": "^1.9.1", + "@stoplight/spectral-runtime": "^1.1.2", + "@stoplight/types": "^13.6.0", + "@types/json-schema": "^7.0.7", + "ajv": "^8.17.1", + "ajv-formats": "~2.1.1", + "json-schema-traverse": "^1.0.0", + "leven": "3.1.0", + "lodash": "~4.17.21", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/spectral-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@stoplight/spectral-runtime/-/spectral-runtime-1.1.4.tgz", + "integrity": "sha512-YHbhX3dqW0do6DhiPSgSGQzr6yQLlWybhKwWx0cqxjMwxej3TqLv3BXMfIUYFKKUqIwH4Q2mV8rrMM8qD2N0rQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/json": "^3.20.1", + "@stoplight/path": "^1.3.2", + "@stoplight/types": "^13.6.0", + "abort-controller": "^3.0.0", + "lodash": "^4.17.21", + "node-fetch": "^2.7.0", + "tslib": "^2.8.1" + }, + "engines": { + "node": "^16.20 || ^18.18 || >= 20.17" + } + }, + "node_modules/@stoplight/types": { + "version": "13.6.0", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-13.6.0.tgz", + "integrity": "sha512-dzyuzvUjv3m1wmhPfq82lCVYGcXG0xUYgqnWfCq3PCVR4BKFhjdkHrnJ+jIDoMKvXb05AZP/ObQF6+NpDo29IQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, + "node_modules/@stoplight/yaml": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@stoplight/yaml/-/yaml-4.3.0.tgz", + "integrity": "sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@stoplight/ordered-object-literal": "^1.0.5", + "@stoplight/types": "^14.1.1", + "@stoplight/yaml-ast-parser": "0.0.50", + "tslib": "^2.2.0" + }, + "engines": { + "node": ">=10.8" + } + }, + "node_modules/@stoplight/yaml-ast-parser": { + "version": "0.0.50", + "resolved": "https://registry.npmjs.org/@stoplight/yaml-ast-parser/-/yaml-ast-parser-0.0.50.tgz", + "integrity": "sha512-Pb6M8TDO9DtSVla9yXSTAxmo9GVEouq5P40DWXdOie69bXogZTkgvopCq+yEvTMA0F6PEvdJmbtTV3ccIp11VQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@stoplight/yaml/node_modules/@stoplight/types": { + "version": "14.1.1", + "resolved": "https://registry.npmjs.org/@stoplight/types/-/types-14.1.1.tgz", + "integrity": "sha512-/kjtr+0t0tjKr+heVfviO9FrU/uGLc+QNX3fHJc19xsCNYqU7lVhaXxDmEID9BZTjG+/r9pK9xP/xU02XGg65g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.4", + "utility-types": "^3.10.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + } + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.1.0.tgz", @@ -6733,6 +7214,16 @@ "@types/ms": "*" } }, + "node_modules/@types/es-aggregate-error": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/es-aggregate-error/-/es-aggregate-error-1.0.6.tgz", + "integrity": "sha512-qJ7LIFp06h1QE1aVxbVd+zJP2wdaugYXYfd6JxsyRMrYHaxb6itXPogW2tz+ylUJ1n1b+JF1PHyYCfYHm0dvUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/eslint": { "version": "8.56.12", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.56.12.tgz", @@ -7168,6 +7659,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/urijs": { + "version": "1.19.26", + "resolved": "https://registry.npmjs.org/@types/urijs/-/urijs-1.19.26.tgz", + "integrity": "sha512-wkXrVzX5yoqLnndOwFsieJA7oKM8cNkOKJtf/3vVGSUFkWDKZvFHpIl9Pvqb/T9UsawBBFMTTD8xu7sK5MWuvg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/@types/which/-/which-2.0.2.tgz", @@ -11865,6 +12363,31 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-draft-04": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/ajv-draft-04/-/ajv-draft-04-1.0.0.tgz", + "integrity": "sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.5.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-errors": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-3.0.0.tgz", + "integrity": "sha512-V3wD15YHfHz6y0KdhYFjyy9vWtEVALT9UrxfN3zqlI6dMioHnJrqOYfyPKol3oqrnCM9uwkcdCwkJ0WUcbLMTQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^8.0.1" + } + }, "node_modules/ajv-formats": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", @@ -12172,6 +12695,49 @@ "proxy-from-env": "^1.1.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/@appium/docutils": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@appium/docutils/-/docutils-2.2.1.tgz", + "integrity": "sha512-mhyQuvQUGepH+MEOGt3ixTM5q1NNVsxr+jvz/6t7KFJm8ElRlfyGGWcA3fqvnXm72hm3rzhh2t13sLct7qWUBg==", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "@appium/support": "^7.0.5", + "consola": "3.4.2", + "diff": "8.0.3", + "lilconfig": "3.1.3", + "lodash": "4.17.23", + "package-directory": "8.1.0", + "read-pkg": "10.0.0", + "teen_process": "4.0.8", + "type-fest": "5.4.1", + "yaml": "2.8.2", + "yargs": "18.0.0", + "yargs-parser": "22.0.0" + }, + "bin": { + "appium-docs": "bin/appium-docs.js" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/@appium/docutils/node_modules/teen_process": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/teen_process/-/teen_process-4.0.8.tgz", + "integrity": "sha512-0DTX2KfgVOr6+8TVmheEdiJHZ/bPOPeJuX0yvv5VOX3x+OFteNkmWkI+hX6zTkzxjddrktsrXkacfS2Gom1YyA==", + "extraneous": true, + "license": "Apache-2.0", + "dependencies": { + "lodash": "^4.17.21", + "shell-quote": "^1.8.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0", + "npm": ">=10" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/@appium/logger": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@appium/logger/-/logger-2.0.4.tgz", @@ -12346,6 +12912,16 @@ "node": ">=0.1.90" } }, + "node_modules/appium-uiautomator2-driver/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", + "integrity": "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/@img/colour": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", @@ -12785,6 +13361,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/appium-uiautomator2-driver/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "extraneous": true, + "license": "Python-2.0" + }, "node_modules/appium-uiautomator2-driver/node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -13026,6 +13609,92 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/appium-uiautomator2-driver/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/chalk/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "extraneous": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/cliui/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -13083,6 +13752,16 @@ "node": ">= 14" } }, + "node_modules/appium-uiautomator2-driver/node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -13280,6 +13959,16 @@ "license": "MIT", "optional": true }, + "node_modules/appium-uiautomator2-driver/node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "extraneous": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -13382,6 +14071,16 @@ "node": ">= 0.4" } }, + "node_modules/appium-uiautomator2-driver/node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -13685,6 +14384,29 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/appium-uiautomator2-driver/node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/get-east-asian-width": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", + "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -13839,6 +14561,16 @@ "license": "MIT", "optional": true }, + "node_modules/appium-uiautomator2-driver/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -14246,6 +14978,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/lockfile": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz", @@ -14400,6 +15145,16 @@ "url": "https://opencollective.com/express" } }, + "node_modules/appium-uiautomator2-driver/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/minimalistic-assert": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", @@ -14594,6 +15349,22 @@ "wrappy": "1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/p-limit": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-7.3.0.tgz", @@ -15634,6 +16405,13 @@ "utf8-byte-length": "^1.0.1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "extraneous": true, + "license": "0BSD" + }, "node_modules/appium-uiautomator2-driver/node_modules/type-fest": { "version": "5.4.1", "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.4.1.tgz", @@ -15774,6 +16552,24 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi-cjs": { "name": "wrap-ansi", "version": "7.0.0", @@ -15793,6 +16589,60 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "extraneous": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -15842,6 +16692,101 @@ "node": ">=0.6.0" } }, + "node_modules/appium-uiautomator2-driver/node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yaml": { + "version": "2.8.2", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", + "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "extraneous": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "extraneous": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "extraneous": true, + "license": "MIT" + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/appium-uiautomator2-driver/node_modules/yargs/node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "extraneous": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/appium-uiautomator2-driver/node_modules/yauzl": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.2.0.tgz", @@ -16525,6 +17470,16 @@ "node": ">=4" } }, + "node_modules/astring": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", + "integrity": "sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==", + "dev": true, + "license": "MIT", + "bin": { + "astring": "bin/astring" + } + }, "node_modules/async": { "version": "3.2.6", "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", @@ -17880,6 +18835,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/caller-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", @@ -21157,6 +22119,16 @@ "node": ">= 0.8" } }, + "node_modules/dependency-graph": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/dependency-graph/-/dependency-graph-0.11.0.tgz", + "integrity": "sha512-JeMq7fEshyepOWDfcfHK06N3MhyPhz++vtqWhMT5O9A3K42rdsEDpfdVqjaqaAhsw6a+ZqeDvQVtD0hFHQWrzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/deprecation": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/deprecation/-/deprecation-2.3.1.tgz", @@ -21987,27 +22959,27 @@ } }, "node_modules/es-abstract": { - "version": "1.23.9", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.9.tgz", - "integrity": "sha512-py07lI0wjxAC/DcfK1S6G7iANonniZwTISvdPzk9hzeH0IZIshbuuFxLIU96OyF89Yb9hiqWn8M/bY83KY5vzA==", + "version": "1.24.1", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.1.tgz", + "integrity": "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw==", "license": "MIT", "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", + "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", + "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", - "get-intrinsic": "^1.2.7", - "get-proto": "^1.0.0", + "get-intrinsic": "^1.3.0", + "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", @@ -22019,21 +22991,24 @@ "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", + "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", + "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", - "is-weakref": "^1.1.0", + "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", - "object-inspect": "^1.13.3", + "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", - "regexp.prototype.flags": "^1.5.3", + "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", + "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", @@ -22042,7 +23017,30 @@ "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", - "which-typed-array": "^1.1.18" + "which-typed-array": "^1.1.19" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-aggregate-error": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/es-aggregate-error/-/es-aggregate-error-1.0.14.tgz", + "integrity": "sha512-3YxX6rVb07B5TV11AV5wsL7nQCHXNwoHPsQC8S4AmBiqYhyNCJ5BRKXkXyDJvs8QzXN20NgRtxe3dEEQD9NLHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.0", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "globalthis": "^1.0.4", + "has-property-descriptors": "^1.0.2", + "set-function-name": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -24303,6 +25301,13 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-memoize": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", + "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -24676,11 +25681,18 @@ } }, "node_modules/for-each": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", - "integrity": "sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "license": "MIT", "dependencies": { - "is-callable": "^1.1.3" + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/foreground-child": { @@ -26296,6 +27308,17 @@ "integrity": "sha512-HR7EVodfFUdQCTIeySw+WDRFJlPcLOJbXfwwZ7Oom6tjsvZ3bOkCDJHehQC3nxJrv7+f9XecwazynjU8e4Vw3Q==", "dev": true }, + "node_modules/immer": { + "version": "9.0.21", + "resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz", + "integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==", + "dev": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", @@ -26943,6 +27966,18 @@ "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", "dev": true }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-npm": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-4.0.0.tgz", @@ -27185,12 +28220,12 @@ } }, "node_modules/is-weakref": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.0.tgz", - "integrity": "sha512-SXM8Nwyys6nT5WP6pltOwKytLV7FqQ4UiibxVmW+EIosHcmCqkkjViTb5SNssDlkCiEYRP1/pdWUKVvZBmsR2Q==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", "license": "MIT", "dependencies": { - "call-bound": "^1.0.2" + "call-bound": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -28289,6 +29324,16 @@ "node": ">= 12" } }, + "node_modules/jsep": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -28500,6 +29545,25 @@ "node >= 0.2.0" ] }, + "node_modules/jsonpath-plus": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.4.0.tgz", + "integrity": "sha512-T92WWatJXmhBbKsgH/0hl+jxjdXrifi5IKeMY02DWggRxX0UElcbVzPlmgLTbvsPeW1PasQ6xE2Q75stkhGbsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsep-plugin/assignment": "^1.3.0", + "@jsep-plugin/regex": "^1.0.4", + "jsep": "^1.4.0" + }, + "bin": { + "jsonpath": "bin/jsonpath-cli.js", + "jsonpath-plus": "bin/jsonpath-cli.js" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/jsonpointer": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", @@ -29982,6 +31046,13 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.topath": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/lodash.topath/-/lodash.topath-4.5.2.tgz", + "integrity": "sha512-1/W4dM+35DwvE/iEd1M9ekewOSTlpFekhw9mhAtrwjVqUr83/ilQiyAvmg4tVX7Unkcfl1KC+i9WdaT4B6aQcg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", @@ -31501,6 +32572,26 @@ "node": ">=0.10.0" } }, + "node_modules/nimma": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/nimma/-/nimma-0.2.3.tgz", + "integrity": "sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jsep-plugin/regex": "^1.0.1", + "@jsep-plugin/ternary": "^1.0.2", + "astring": "^1.8.1", + "jsep": "^1.2.0" + }, + "engines": { + "node": "^12.20 || >=14.13" + }, + "optionalDependencies": { + "jsonpath-plus": "^6.0.1 || ^10.1.0", + "lodash.topath": "^4.5.2" + } + }, "node_modules/node-addon-api": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", @@ -32227,6 +33318,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/opener": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/opener/-/opener-1.5.2.tgz", @@ -33250,6 +34349,16 @@ "node": ">=4" } }, + "node_modules/pony-cause": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/pony-cause/-/pony-cause-1.1.1.tgz", + "integrity": "sha512-PxkIc/2ZpLiEzQXu5YRDOUgBlfGYBY8156HY5ZcRAwwonMk5W/MrJP2LLkG/hF7GEQzaHo2aS7ho6ZLCOvf+6g==", + "dev": true, + "license": "0BSD", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", @@ -36974,6 +38083,19 @@ } ] }, + "node_modules/simple-eval": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-eval/-/simple-eval-1.0.1.tgz", + "integrity": "sha512-LH7FpTAkeD+y5xQC4fzS+tFtaNlvt3Ib1zKzvhjv/Y+cioV4zIuw4IZr2yhRLu67CWL7FR9/6KXKnjRoZTvGGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsep": "^1.3.6" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/simple-fmt": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/simple-fmt/-/simple-fmt-0.1.0.tgz", @@ -37513,6 +38635,19 @@ "node": ">=0.10.0" } }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/stream-browserify": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", @@ -38041,6 +39176,106 @@ "node": ">=16" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/syntax-error": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/syntax-error/-/syntax-error-1.4.0.tgz", @@ -38741,6 +39976,137 @@ "typescript": ">=4.0.0" } }, + "node_modules/ts-json-schema-generator": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.9.0.tgz", + "integrity": "sha512-NR5ZE108uiPtBHBJNGnhwoUaUx5vWTDJzDFG9YlRoqxPU76n+5FClRh92dcGgysbe1smRmYalM9Saj97GW1J4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "commander": "^14.0.3", + "glob": "^13.0.6", + "json5": "^2.2.3", + "normalize-path": "^3.0.0", + "safe-stable-stringify": "^2.5.0", + "tslib": "^2.8.1", + "typescript": "^5.9.3" + }, + "bin": { + "ts-json-schema-generator": "bin/ts-json-schema-generator.js" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/ts-json-schema-generator/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ts-json-schema-generator/node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/ts-json-schema-generator/node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-json-schema-generator/node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/ts-json-schema-generator/node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ts-json-schema-generator/node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/ts-node": { "version": "10.9.2", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", @@ -39577,6 +40943,13 @@ "node": ">=6" } }, + "node_modules/urijs": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", + "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/url": { "version": "0.11.4", "resolved": "https://registry.npmjs.org/url/-/url-0.11.4.tgz", @@ -39677,6 +41050,16 @@ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", @@ -40645,15 +42028,16 @@ "dev": true }, "node_modules/which-typed-array": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.18.tgz", - "integrity": "sha512-qEcY+KJYlWyLH9vNbsr6/5j59AXk5ni5aakf8ldzBvGde6Iz4sxZGkJyWSAueTG7QhOvNRYb1lDdFmL5Td0QKA==", + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", "license": "MIT", "dependencies": { "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", - "call-bound": "^1.0.3", - "for-each": "^0.3.3", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2" }, @@ -41665,6 +43049,48 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, + "node_modules/z-schema/node_modules/validator": { + "version": "13.15.26", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.26.tgz", + "integrity": "sha512-spH26xU080ydGggxRyR1Yhcbgx+j3y5jbNXk/8L+iRvdIEQ4uTRH2Sgf2dokud6Q4oAtsbNvJ1Ft+9xmm6IZcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", diff --git a/package.json b/package.json index 7579026f49f..c3a2fc2bbcf 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,9 @@ "@medic/translation-checker": "^1.1.0", "@octokit/core": "^5.2.0", "@octokit/plugin-paginate-graphql": "^4.0.1", + "@stoplight/spectral-core": "^1.21.0", + "@stoplight/spectral-parsers": "^1.0.5", + "@stoplight/spectral-rulesets": "^1.22.0", "@stylistic/eslint-plugin": "^5.1.0", "@tsconfig/node22": "^22.0.2", "@types/chai": "^4.3.6", @@ -182,7 +185,9 @@ "shellcheck": "^4.1.0", "sinon": "^21.0.1", "sinon-chai": "^3.7.0", + "swagger-jsdoc": "^6.2.8", "tail": "^2.2.6", + "ts-json-schema-generator": "^2.9.0", "ts-node": "^10.9.2", "typedoc": "^0.28.7", "typescript": "5.7.3", diff --git a/scripts/build/build-documentation.sh b/scripts/build/build-documentation.sh index d96163881e6..8b342d64362 100755 --- a/scripts/build/build-documentation.sh +++ b/scripts/build/build-documentation.sh @@ -1,10 +1,13 @@ #!/bin/bash set -e +shopt -s extglob echo "build-documentation: building jsdocs" -jsdoc -d jsdocs/admin -c admin/node_modules/angular-jsdoc/common/conf.json -t admin/node_modules/angular-jsdoc/angular-template admin/src/js/**/*.js +(cd admin && jsdoc -d ../jsdocs/admin -c node_modules/angular-jsdoc/common/conf.json -t node_modules/angular-jsdoc/angular-template src/js/**/*.js) jsdoc -d jsdocs/sentinel sentinel/src/**/*.js jsdoc -d jsdocs/api -R api/README.md api/src/**/*.js -jsdoc -d jsdocs/shared-libs shared-libs/**/src/**/*.js +jsdoc -d jsdocs/shared-libs shared-libs/!(cht-datasource)/src/**/*.js +npm run --prefix shared-libs/cht-datasource gen-docs +node scripts/build/generate-openapi.js echo "build-documentation: done" diff --git a/scripts/build/generate-openapi.js b/scripts/build/generate-openapi.js new file mode 100644 index 00000000000..09024d0d8d1 --- /dev/null +++ b/scripts/build/generate-openapi.js @@ -0,0 +1,175 @@ +const path = require('node:path'); +const fs = require('node:fs'); +const swaggerJsdoc = require('swagger-jsdoc'); +const tsj = require('ts-json-schema-generator'); +const { Spectral, Document } = require('@stoplight/spectral-core'); +const Parsers = require('@stoplight/spectral-parsers'); +const { oas } = require('@stoplight/spectral-rulesets'); +const { version } = require('../../package.json'); + +const DATASOURCE_DIR = path.resolve(__dirname, '../../shared-libs/cht-datasource/src'); +const TSCONFIG = path.resolve(__dirname, '../../shared-libs/cht-datasource/tsconfig.build.json'); + +const TYPE_SOURCES = [ + 'contact.ts', + 'person.ts', + 'place.ts', + 'report.ts', + 'target.ts', + 'input.ts', +].map(file => path.join(DATASOURCE_DIR, file)); + +const TSJ_OPTIONS = { + tsconfig: TSCONFIG, + type: '*', + skipTypeCheck: true, + discriminatorType: 'open-api', + functions: 'hide', +}; + +const SWAGGER_OPTIONS = { + failOnErrors: true, + definition: { + openapi: '3.1.0', + info: { + title: 'CHT API', + version: version, + description: 'API for interacting with the Community Health Toolkit', + contact: { + name: 'Medic', + email: 'hello@medic.org', + url: 'https://forum.communityhealthtoolkit.org/' + }, + license: { + name: 'AGPL-3.0', + url: 'https://www.gnu.org/licenses/agpl-3.0.html' + } + }, + servers: [{ url: '/' }], + components: { + schemas: { + PageCursor: { + type: ['string', 'null'], + description: 'Token for retrieving the next page. A `null` value indicates there are no more pages.', + }, + OkResponse: { + type: 'object', + properties: { + ok: { const: true }, + }, + }, + }, + parameters: { + cursor: { + in: 'query', + name: 'cursor', + schema: { type: 'string' }, + description: + 'Token identifying which page to retrieve. Omit for the first page. ' + + 'Subsequent pages can be retrieved by providing the cursor returned with the previous page.', + }, + limitEntity: { + in: 'query', + name: 'limit', + schema: { type: 'number', default: 100, minimum: 1 }, + description: 'The maximum number of entities to return.', + }, + limitId: { + in: 'query', + name: 'limit', + schema: { type: 'number', default: 10000, minimum: 1 }, + description: 'The maximum number of identifiers to return.', + }, + withLineage: { + in: 'query', + name: 'with_lineage', + schema: { 'enum': ['true', 'false'], default: 'false' }, + description: 'Include the full parent lineage.' + } + }, + responses: { + NotFound: { description: 'Entity not found' }, + BadRequest: { description: 'Invalid input (missing required fields, invalid types, etc.)' }, + Unauthorized: { description: 'Not authenticated' }, + Forbidden: { description: 'Insufficient permissions' } + } + }, + }, + apis: [ + path.resolve(__dirname, '../../api/src/routing.js'), + path.resolve(__dirname, '../../api/src/controllers/**/*.js'), + ], +}; + +const SPECTRAL_OPTIONS = { extends: [[oas, 'all']], rules: {} }; + +const transformObject = (obj) => { + const result = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === '$ref') { + result[key] = value.replace('#/definitions/', '#/components/schemas/'); + continue; + } + result[key] = transformSchema(value); + } + + // Simplify complex additionalProperties to just `true` since these are open-ended document types + if (result.additionalProperties) { + result.additionalProperties = true; + } + return result; +}; + +const transformSchema = (schema) => { + if (schema === null || typeof schema !== 'object') { + return schema; + } + if (Array.isArray(schema)) { + return schema.map(transformSchema); + } + return transformObject(schema); +}; + +const generateTsSchemas = () => { + const schemas = {}; + TYPE_SOURCES + .map(path => ({ ...TSJ_OPTIONS, path })) + .map(opts => tsj.createGenerator(opts)) + .map(generator => generator.createSchema()) + .map(({ definitions }) => ({ ...definitions, '*': undefined })) + .forEach((definitions) => Object.assign(schemas, definitions)); + return transformSchema(schemas); +}; + +const DIAGNOSTIC_SEVERITY = ['error', 'warn', 'info', 'hint']; + +const lintSpec = async (spec) => { + const spectral = new Spectral(); + spectral.setRuleset(SPECTRAL_OPTIONS); + const doc = new Document(JSON.stringify(spec), Parsers.Json, '/openapi.json'); + const results = await spectral.run(doc); + + results.forEach(({ + severity, code, message, path + }) => console.log(` [${DIAGNOSTIC_SEVERITY[severity]}] ${code}: ${message} (at ${path.join('.')})`)); + + const errors = results.filter(r => r.severity <= 1); + if (errors.length > 0) { + throw new Error(`OpenAPI spec has ${errors.length} validation error(s)`); + } +}; + +const main = async () => { + const swaggerSpec = swaggerJsdoc(SWAGGER_OPTIONS); + const tsSchemas = generateTsSchemas(); + Object.assign(swaggerSpec.components.schemas, tsSchemas); + swaggerSpec.tags.sort((a, b) => a.name.localeCompare(b.name)); + await lintSpec(swaggerSpec); + const outputPath = path.resolve(__dirname, '../../shared-libs/cht-datasource/docs/openapi.json'); + fs.writeFileSync(outputPath, JSON.stringify(swaggerSpec)); +}; + +main().catch((err) => { + console.error(err.message); + process.exit(1); +}); diff --git a/shared-libs/cht-datasource/src/input.ts b/shared-libs/cht-datasource/src/input.ts index a14af3311c4..d5271a83227 100644 --- a/shared-libs/cht-datasource/src/input.ts +++ b/shared-libs/cht-datasource/src/input.ts @@ -8,10 +8,10 @@ import { Doc } from './libs/doc'; export namespace v1 { /** * Input data for a contact. + * @internal */ export interface ContactInput extends DataObject { readonly type: string - readonly contact_type?: string readonly name: string readonly reported_date?: DateTimeString | number readonly _id?: never diff --git a/shared-libs/cht-datasource/src/libs/core.ts b/shared-libs/cht-datasource/src/libs/core.ts index 34ab3b62e66..59e0f4efa5c 100644 --- a/shared-libs/cht-datasource/src/libs/core.ts +++ b/shared-libs/cht-datasource/src/libs/core.ts @@ -52,7 +52,9 @@ const isDataArray = (value: unknown): value is DataArray => { return Array.isArray(value) && value.every(v => isDataPrimitive(v) || isDataArray(v) || isDataObject(v)); }; -/** @internal */ +/** + * A data object. + */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface DataObject extends Readonly> { } @@ -175,7 +177,9 @@ export function assertHasRequiredField , N ext } } -/** @internal */ +/** + * An identifiable entity. + */ export interface Identifiable extends DataObject { readonly _id: string } @@ -222,7 +226,9 @@ export const getPagedGenerator = async function* ( return null; }; -/** @internal */ +/** + * Parent lineage data for an entity. + */ export interface NormalizedParent extends DataObject, Identifiable { readonly parent?: NormalizedParent; }