From 374a3f4dab7bf893ba3c579fa7f9c346fa30ad64 Mon Sep 17 00:00:00 2001 From: Shahed Nasser Date: Tue, 13 Feb 2024 10:40:04 +0200 Subject: [PATCH] docs-util: support generating OAS in docblock generator (#6338) ## What This PR adds support for generating OAS in the docblock generator tool. ## How As OAS are generated in a different manner/location than regular TSDocs, it requires a new type of generator within the tool. As such, the existing docblock generator now only handles files that aren't under the `packages/medusa/src/api` and `packages/medusa/src/api-v2` directories. The new generator handles files under these directories. However, it only considers a node to be an API route if it's a function having two parameters of types `MedusaRequest` and `MedusaResponse` respectively. So, only new API Routes are considered. The new generator runs the same way as the existing docblock generator with the same method. The generators will detect whether they can run on the file or not and the docblocks/oas are generated based on that. I've also added a `--type` option to the CLI commands of the docblock generator tool to further filter and choose which generator to use. When the OAS generator finds an API route, it will generate its OAS under the `docs-util/oas-output/operations` directory in a TypeScript file. I chose to generate in TS files rather than YAML files to maintain the functionality of `medusa-oas` without major changes. Schemas detected in the OAS operation, such as the request and response schemas, are generated as OAS schemas under the `docs-util/oas-output/schemas` directory and referenced in operations and other resources. The OAS generator also handles updating OAS. When you run the same command on a file/directory and an API route already has OAS associated with it, its information and associated schemas are updated instead of generating new schemas/operations. However, summaries and descriptions aren't updated unless they're not available or their values are the default value SUMMARY. ## API Route Handling ### Request and Response Types The tool extracts the type of request/response schemas from the type arguments passed to the `MedusaRequest` and `MedusaResponse` respectively. For example: ```ts export const POST = async ( req: MedusaRequest<{ id: string }>, res: MedusaResponse ) => { // ... } ``` If these types aren't provided, the request/response is considered empty. ### Path Parameters Path parameters are extracted from the file's path name. For example, for `packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts` the `id` path parameter is extracted. ### Query Parameters The tool extracts the query parameters of an API route based on the type of `request.validatedQuery`. Once we narrow down how we're typing query parameters, we can revisit this implementation. ## Changes to Medusa Oas CLI I added a `--v2` option to the Medusa OAS CLI to support loading OAS from `docs-util/oas-output` directory rather than the `medusa` package. This will output the OAS in `www/apps/api-reference/specs`, wiping out old OAS. This is only helpful for testing purposes to check how the new OAS looks like in the API reference. It also allows us to slowly start adapting the new OAS. ## Other Notes and Changes - I've added a GitHub action that creates a PR for generated OAS when Version Packages is merged (similar to regular TSDocs). However, this will only generate the OAS in the `docs-util/oas-output` directory and will not affect the existing OAS in the API reference. Once we're ready to include it those OAS, we can talk about next steps. - I've moved the base YAML from the `medusa` package to the `docs-util/oas-output/base` directory and changed the `medusa-oas` tool to load them from there. - I added a `clean:oas` command to the docblock generator CLI tool that removes unused OAS operations, schemas, and tags from `docs-util/oas-output`. The tool also supports updating OAS operations and their associated schemas. However, I didn't add a specific mechanism to update schemas on their own as that's a bit tricky and would require the help of typedoc. I believe with the process of running the tool on the `api-v2` directory whenever there's a new release should be enough to update associated schemas, but if we find that not enough, we can revisit updating schemas individually. - Because of the `clean:oas` command which makes changes to tags (removing the existing ones, more details on this one later), I've added new base YAML under `docs-util/oas-output/base-v2`. This is used by the tool when generating/cleaning OAS, and the Medusa OAS CLI when the `--v2` option is used. ## Testing ### Prerequisites To test with request/response types, I recommend minimally modifying `packages/medusa/src/types/routing.ts` to allow type arguments of `MedusaRequest` and `MedusaResponse`: ```ts import type { NextFunction, Request, Response } from "express" import type { Customer, User } from "../models" import type { MedusaContainer } from "./global" export interface MedusaRequest extends Request { user?: (User | Customer) & { customer_id?: string; userId?: string } scope: MedusaContainer } export type MedusaResponse = Response export type MedusaNextFunction = NextFunction export type MedusaRequestHandler = ( req: MedusaRequest, res: MedusaResponse, next: MedusaNextFunction ) => Promise | void ``` You can then add type arguments to the routes in `packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts`. For example: ```ts import { deleteCampaignsWorkflow, updateCampaignsWorkflow, } from "@medusajs/core-flows" import { ModuleRegistrationName } from "@medusajs/modules-sdk" import { CampaignDTO, IPromotionModuleService } from "@medusajs/types" import { MedusaRequest, MedusaResponse } from "../../../../types/routing" interface ResponseType { campaign: CampaignDTO } export const GET = async ( req: MedusaRequest, res: MedusaResponse ) => { const promotionModuleService: IPromotionModuleService = req.scope.resolve( ModuleRegistrationName.PROMOTION ) const campaign = await promotionModuleService.retrieveCampaign( req.params.id, { select: req.retrieveConfig.select, relations: req.retrieveConfig.relations, } ) res.status(200).json({ campaign }) } export const POST = async ( req: MedusaRequest<{ id: string }>, res: MedusaResponse ) => { const updateCampaigns = updateCampaignsWorkflow(req.scope) const campaignsData = [ { id: req.params.id, ...(req.validatedBody || {}), }, ] const { result, errors } = await updateCampaigns.run({ input: { campaignsData }, throwOnError: false, }) if (Array.isArray(errors) && errors[0]) { throw errors[0].error } res.status(200).json({ campaign: result[0] }) } export const DELETE = async ( req: MedusaRequest, res: MedusaResponse<{ id: string object: string deleted: boolean }> ) => { const id = req.params.id const manager = req.scope.resolve("manager") const deleteCampaigns = deleteCampaignsWorkflow(req.scope) const { errors } = await deleteCampaigns.run({ input: { ids: [id] }, context: { manager }, throwOnError: false, }) if (Array.isArray(errors) && errors[0]) { throw errors[0].error } res.status(200).json({ id, object: "campaign", deleted: true, }) } ``` ### Generate OAS - Install dependencies in the `docs-util` directory - Run the following command in the `docs-util/packages/docblock-generator` directory: ```bash yarn dev run "../../../packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts" ``` This will generate the OAS operation and schemas and necessary and update the base YAML to include the new tags. ### Generate OAS with Examples By default, the tool will only generate cURL examples for OAS operations. To generate templated JS Client and (placeholder) Medusa React examples, add the `--generate-examples` option to the command: ```bash yarn dev run "../../../packages/medusa/src/api-v2/admin/campaigns/[id]/route.ts" --generate-examples ``` > Note: the command will update the existing OAS you generated in the previous test. ### Testing Updates To test updating OAS, you can try updating request/response types, then running the command, and the associated OAS/schemas will be updated. ### Clean OAS The `clean:oas` command will remove any unused operation, tags, or schemas. To test it out you can try: - Remove an API Route => this removes its associated operation and schemas (if not referenced anywhere else). - Remove all references to a schema => this removes the schema. - Remove all operations in `docs-util/oas-output/operations` associated with a tag => this removes the tag from the base YAML. ```bash yarn dev clean:oas ``` > Note: when running this command, existing tags in the base YAML (such as Products) will be removed since there are no operations using it. As it's running on the base YAML under `base-v2`, this doesn't affect base YAML used for the API reference. ### Medusa Oas CLI - Install and build dependencies in the root of the monorepo - Run the following command to generate reference OAS for v2 API Routes (must have generated OAS previously using the docblock generator tool): ```bash yarn openapi:generate --v2 ``` - This wipes out existing OAS in `www/apps/api-reference/specs` and replaces them with the new ones. At this point, you can view the new API routes in the API reference by running the `yarn dev` command in `www/apps/api-reference` (although not necessary for testing here). - Run the command again without the `--v2` option: ```bash yarn openapi:generate ``` The specs in `www/apps/api-reference/specs` are reverted back to the old routes. --- .changeset/old-boats-fold.md | 7 + .github/workflows/generate-docblocks.yml | 60 +- docs-util/.eslintrc.js | 4 +- .../oas-output/base-v2}/admin.oas.base.yaml | 336 +-- .../oas-output/base-v2}/store.oas.base.yaml | 0 docs-util/oas-output/base/admin.oas.base.yaml | 462 ++++ docs-util/oas-output/base/store.oas.base.yaml | 287 +++ .../packages/docblock-generator/README.md | 55 +- .../packages/docblock-generator/package.json | 7 +- .../src/classes/examples/oas.ts | 383 ++++ .../docblock.ts} | 84 +- .../src/classes/generators/index.ts | 110 + .../src/classes/generators/oas.ts | 76 + .../src/classes/{ => helpers}/formatter.ts | 44 +- .../helpers/generator-event-manager.ts | 37 + .../src/classes/{ => helpers}/git-manager.ts | 0 .../{ => helpers}/knowledge-base-factory.ts | 53 +- .../src/classes/helpers/oas-schema.ts | 233 ++ .../src/classes/kinds/default.ts | 140 +- .../src/classes/kinds/function.ts | 2 +- .../src/classes/kinds/medusa-react-hooks.ts | 3 +- .../src/classes/kinds/oas.ts | 1870 +++++++++++++++++ .../src/classes/kinds/registry.ts | 21 +- .../src/commands/clean-oas.ts | 271 +++ .../src/commands/run-git-changes.ts | 32 +- .../src/commands/run-git-commit.ts | 31 +- .../src/commands/run-release.ts | 33 +- .../docblock-generator/src/commands/run.ts | 28 +- .../docblock-generator/src/constants.ts | 27 +- .../packages/docblock-generator/src/index.ts | 28 +- .../docblock-generator/src/types/index.d.ts | 30 + .../src/utils/format-oas.ts | 20 + .../src/utils/get-oas-output-base-path.ts | 6 + .../src/utils/node-has-comments.ts | 16 - .../docblock-generator/src/utils/parse-oas.ts | 42 + .../src/utils/str-formatting.ts | 39 + docs-util/yarn.lock | 37 +- packages/medusa/src/types/common.ts | 16 +- .../src/__tests__/command-oas.test.ts | 18 +- .../oas/medusa-oas-cli/src/command-oas.ts | 34 +- .../oas-github-ci/scripts/build-openapi.js | 7 +- 41 files changed, 4677 insertions(+), 312 deletions(-) create mode 100644 .changeset/old-boats-fold.md rename {packages/medusa/oas => docs-util/oas-output/base-v2}/admin.oas.base.yaml (70%) rename {packages/medusa/oas => docs-util/oas-output/base-v2}/store.oas.base.yaml (100%) create mode 100644 docs-util/oas-output/base/admin.oas.base.yaml create mode 100644 docs-util/oas-output/base/store.oas.base.yaml create mode 100644 docs-util/packages/docblock-generator/src/classes/examples/oas.ts rename docs-util/packages/docblock-generator/src/classes/{docblock-generator.ts => generators/docblock.ts} (52%) create mode 100644 docs-util/packages/docblock-generator/src/classes/generators/index.ts create mode 100644 docs-util/packages/docblock-generator/src/classes/generators/oas.ts rename docs-util/packages/docblock-generator/src/classes/{ => helpers}/formatter.ts (86%) create mode 100644 docs-util/packages/docblock-generator/src/classes/helpers/generator-event-manager.ts rename docs-util/packages/docblock-generator/src/classes/{ => helpers}/git-manager.ts (100%) rename docs-util/packages/docblock-generator/src/classes/{ => helpers}/knowledge-base-factory.ts (87%) create mode 100644 docs-util/packages/docblock-generator/src/classes/helpers/oas-schema.ts create mode 100644 docs-util/packages/docblock-generator/src/classes/kinds/oas.ts create mode 100644 docs-util/packages/docblock-generator/src/commands/clean-oas.ts create mode 100644 docs-util/packages/docblock-generator/src/types/index.d.ts create mode 100644 docs-util/packages/docblock-generator/src/utils/format-oas.ts create mode 100644 docs-util/packages/docblock-generator/src/utils/get-oas-output-base-path.ts delete mode 100644 docs-util/packages/docblock-generator/src/utils/node-has-comments.ts create mode 100644 docs-util/packages/docblock-generator/src/utils/parse-oas.ts diff --git a/.changeset/old-boats-fold.md b/.changeset/old-boats-fold.md new file mode 100644 index 0000000000..b160cd491f --- /dev/null +++ b/.changeset/old-boats-fold.md @@ -0,0 +1,7 @@ +--- +"@medusajs/medusa-oas-cli": patch +"@medusajs/oas-github-ci": patch +--- + +feat(@medusajs/medusa-oas-cli): added v2 flag +feat(@medusajs/oas-github-ci): added v2 flag \ No newline at end of file diff --git a/.github/workflows/generate-docblocks.yml b/.github/workflows/generate-docblocks.yml index e514cd09da..d2aee07eb3 100644 --- a/.github/workflows/generate-docblocks.yml +++ b/.github/workflows/generate-docblocks.yml @@ -40,7 +40,7 @@ jobs: GIT_REPO: medusa - name: Run docblock generator - if: steps.check-commit.outputs.is_release_commit == true + if: steps.check-commit.outputs.is_release_commit == 'true' run: "yarn start run:release" working-directory: docs-util/packages/docblock-generator env: @@ -49,13 +49,65 @@ jobs: GIT_REPO: medusa - name: Create Pull Request - if: steps.check-commit.outputs.is_release_commit == true + if: steps.check-commit.outputs.is_release_commit == 'true' uses: peter-evans/create-pull-request@v5 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: - title: "Generated TSDocs" + title: "chore(docs): Generated TSDocs" body: "This PR holds all generated TSDocs for the upcoming release." branch: "chore/generate-tsdocs" team-reviewers: "@medusajs/docs" - add-paths: packages/** \ No newline at end of file + add-paths: packages/** + generate-oas: + name: Generated OAS PR + runs-on: ubuntu-latest + steps: + - name: Checkout Repo + uses: actions/checkout@v3 + + - name: Setup Node.js 18 + uses: actions/setup-node@v3 + with: + node-version: 18 + + - name: Install Dependencies + run: yarn + + - name: Install docs-util Dependencies + run: yarn + working-directory: docs-util + + - name: Build packages + run: yarn build + working-directory: docs-util + + - name: Check Commit + id: check-commit + run: 'yarn check:release-commit ${{ github.sha }}' + working-directory: docs-util/packages/scripts + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GIT_OWNER: ${{ github.repository_owner }} + GIT_REPO: medusa + + - name: Run docblock generator + if: steps.check-commit.outputs.is_release_commit == 'true' + run: "yarn start run ../../../packages/medusa/src/api-v2 --type oas && yarn start clean:oas" + working-directory: docs-util/packages/docblock-generator + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GIT_OWNER: ${{ github.repository_owner }} + GIT_REPO: medusa + + - name: Create Pull Request + if: steps.check-commit.outputs.is_release_commit == 'true' + uses: peter-evans/create-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + title: "chore(docs): Generated OAS" + body: "This PR holds all generated OAS for the upcoming release." + branch: "chore/generate-oas" + team-reviewers: "@medusajs/docs" + add-paths: docs-util/oas-output/** \ No newline at end of file diff --git a/docs-util/.eslintrc.js b/docs-util/.eslintrc.js index 00efa299e4..262ff4f304 100644 --- a/docs-util/.eslintrc.js +++ b/docs-util/.eslintrc.js @@ -72,6 +72,7 @@ module.exports = { ], "space-infix-ops": "error", "eol-last": ["error", "always"], + "no-case-declarations": "off" }, env: { es6: true, @@ -79,7 +80,8 @@ module.exports = { }, ignorePatterns: [ ".eslintrc.js", - "dist" + "dist", + "oas-output" ], overrides: [ { diff --git a/packages/medusa/oas/admin.oas.base.yaml b/docs-util/oas-output/base-v2/admin.oas.base.yaml similarity index 70% rename from packages/medusa/oas/admin.oas.base.yaml rename to docs-util/oas-output/base-v2/admin.oas.base.yaml index a107fecfb2..27870b55e5 100644 --- a/packages/medusa/oas/admin.oas.base.yaml +++ b/docs-util/oas-output/base-v2/admin.oas.base.yaml @@ -7,235 +7,313 @@ info: url: https://github.com/medusajs/medusa/blob/master/LICENSE tags: - name: Apps Oauth - description: | - Some plugins may require to authenticate with third-party services and store authentication details, such as the authentication token. To do that, they can create an Oauth provider within the plugin that handles the authentication. + description: > + Some plugins may require to authenticate with third-party services and + store authentication details, such as the authentication token. To do + that, they can create an Oauth provider within the plugin that handles the + authentication. + The Apps Oauth API Routes allows admins to manage and generate token for an app using its oauth provider. - name: Auth - description: | - Authentication API Routes allow admin users to manage their session, such as login or log out. + description: > + Authentication API Routes allow admin users to manage their session, such + as login or log out. + When an admin user is logged in, the cookie header is set indicating the admin's login session. externalDocs: description: How to implement user profiles url: https://docs.medusajs.com/modules/users/admin/manage-profile - name: Batch Jobs - description: | - A batch job is a task that is performed by the Medusa backend asynchronusly. For example, the Import Product feature is implemented using batch jobs. + description: > + A batch job is a task that is performed by the Medusa backend + asynchronusly. For example, the Import Product feature is implemented + using batch jobs. + Batch Job API Routes allow admins to manage the batch jobs and their state. externalDocs: description: How to import products url: https://docs.medusajs.com/modules/products/admin/import-products - name: Currencies - description: | - A store can use unlimited currencies, and each region must be associated with at least one currency. + description: > + A store can use unlimited currencies, and each region must be associated + with at least one currency. + Currencies are defined within the Medusa backend. Currency API Routes allow admins to list and update currencies. externalDocs: description: How to manage currencies url: https://docs.medusajs.com/modules/regions-and-currencies/admin/manage-currencies - - name: Customers - description: | - Customers can either be created when they register through the Store APIs, or created by the admin using the Admin APIs. - externalDocs: - description: How to manage customers - url: https://docs.medusajs.com/modules/customers/admin/manage-customers - name: Customer Groups - description: | - Customer Groups can be used to organize customers that share similar data or attributes into dedicated groups. + description: > + Customer Groups can be used to organize customers that share similar data + or attributes into dedicated groups. + This can be useful for different purposes such as setting a different price for a specific customer group. externalDocs: description: How to manage customer groups url: https://docs.medusajs.com/modules/customers/admin/manage-customer-groups + - name: Customers + description: > + Customers can either be created when they register through the Store APIs, + or created by the admin using the Admin APIs. + externalDocs: + description: How to manage customers + url: https://docs.medusajs.com/modules/customers/admin/manage-customers - name: Discounts - description: | - Admins can create discounts with conditions and rules, providing them with advanced settings for variety of cases. + description: > + Admins can create discounts with conditions and rules, providing them with + advanced settings for variety of cases. + The Discount API Routes can be used to manage discounts, their conditions, resources, and more. externalDocs: description: How to manage discounts url: https://docs.medusajs.com/modules/discounts/admin/manage-discounts - name: Draft Orders - description: | - A draft order is an order created manually by the admin. It allows admins to create orders without direct involvement from the customer. + description: > + A draft order is an order created manually by the admin. It allows admins + to create orders without direct involvement from the customer. externalDocs: description: How to manage draft orders url: https://docs.medusajs.com/modules/orders/admin/manage-draft-orders - name: Gift Cards - description: | - Admins can create gift cards and send them directly to customers, specifying options like their balance, region, and more. + description: > + Admins can create gift cards and send them directly to customers, + specifying options like their balance, region, and more. + These gift cards are different than the saleable gift cards in a store, which are created and managed through Product API Routes. externalDocs: description: How to manage gift cards url: https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-custom-gift-cards - name: Inventory Items - description: | - Inventory items, provided by the [Inventory Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), can be used to manage the inventory of saleable items in your store. + description: > + Inventory items, provided by the [Inventory + Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), + can be used to manage the inventory of saleable items in your store. externalDocs: description: How to manage inventory items url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-inventory-items - name: Invites - description: | - An admin can invite new users to manage their team. This would allow new users to authenticate as admins and perform admin functionalities. + description: > + An admin can invite new users to manage their team. This would allow new + users to authenticate as admins and perform admin functionalities. externalDocs: description: How to manage invites url: https://docs.medusajs.com/modules/users/admin/manage-invites - name: Notes - description: | - Notes are created by admins and can be associated with any resource. For example, an admin can add a note to an order for additional details or remarks. + description: > + Notes are created by admins and can be associated with any resource. For + example, an admin can add a note to an order for additional details or + remarks. - name: Notifications - description: | - Notifications are sent to customers to inform them of new updates. For example, a notification can be sent to the customer when their order is place or its state is updated. + description: > + Notifications are sent to customers to inform them of new updates. For + example, a notification can be sent to the customer when their order is + place or its state is updated. + The notification's type, such as an email or SMS, is determined by the notification provider installed on the Medusa backend. + - name: Order Edits + description: > + An admin can edit an order to remove, add, or update an item's quantity. + When an admin edits an order, they're stored as an `OrderEdit`. + externalDocs: + description: How to edit an order + url: https://docs.medusajs.com/modules/orders/admin/edit-order - name: Orders - description: | - Orders are purchases made by customers, typically through a storefront using the Store API. Draft orders created by the admin are also transformed to an Order once the payment is captured. + description: > + Orders are purchases made by customers, typically through a storefront + using the Store API. Draft orders created by the admin are also + transformed to an Order once the payment is captured. + Managing orders include managing fulfillment, payment, claims, reservations, and more. externalDocs: description: How to manage orders url: https://docs.medusajs.com/modules/orders/admin/manage-orders - - name: Order Edits - description: | - An admin can edit an order to remove, add, or update an item's quantity. When an admin edits an order, they're stored as an `OrderEdit`. - externalDocs: - description: How to edit an order - url: https://docs.medusajs.com/modules/orders/admin/edit-order - - name: Payments - description: | - A payment can be related to an order, swap, return, or more. It can be captured or refunded. - name: Payment Collections - description: | - A payment collection is useful for managing additional payments, such as for Order Edits, or installment payments. + description: > + A payment collection is useful for managing additional payments, such as + for Order Edits, or installment payments. + - name: Payments + description: > + A payment can be related to an order, swap, return, or more. It can be + captured or refunded. - name: Price Lists - description: | - A price list are special prices applied to products based on a set of conditions, such as customer group. + description: > + A price list are special prices applied to products based on a set of + conditions, such as customer group. externalDocs: description: How to manage price lists url: https://docs.medusajs.com/modules/price-lists/admin/manage-price-lists - - name: Products - description: | - Products are saleable items in a store. This also includes [saleable gift cards](https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-gift-card-product) in a store. - externalDocs: - description: How to manage products - url: https://docs.medusajs.com/modules/products/admin/manage-products - name: Product Categories - description: | - Products can be categoriezed into categories. A product can be added into more than one category. + description: > + Products can be categoriezed into categories. A product can be added into + more than one category. externalDocs: description: How to manage product categories url: https://docs.medusajs.com/modules/products/admin/manage-categories - name: Product Collections - description: | - A product collection is used to organize products for different purposes such as marketing or discount purposes. For example, you can create a Summer Collection. + description: > + A product collection is used to organize products for different purposes + such as marketing or discount purposes. For example, you can create a + Summer Collection. - name: Product Tags - description: | - Product tags are string values created when you create or update a product with a new tag. + description: > + Product tags are string values created when you create or update a product + with a new tag. + Products can have more than one tag, and products can share tags. This allows admins to associate products to similar tags that can be used to filter products. - name: Product Types - description: | - Product types are string values created when you create or update a product with a new type. + description: > + Product types are string values created when you create or update a + product with a new type. + Products can have one type, and products can share types. This allows admins to associate products with a type that can be used to filter products. - name: Product Variants - description: | - Product variants are the actual salable item in your store. Each variant is a combination of the different option values available on the product. + description: > + Product variants are the actual salable item in your store. Each variant + is a combination of the different option values available on the product. + Product variants can be managed through the Products API Routes. externalDocs: description: How to manage product variants url: https://docs.medusajs.com/modules/products/admin/manage-products#manage-product-variants + - name: Products + description: > + Products are saleable items in a store. This also includes [saleable gift + cards](https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-gift-card-product) + in a store. + externalDocs: + description: How to manage products + url: https://docs.medusajs.com/modules/products/admin/manage-products - name: Publishable API Keys - description: | - Publishable API Keys can be used to scope Store API calls with an API key, determining what resources are retrieved when querying the API. + description: > + Publishable API Keys can be used to scope Store API calls with an API key, + determining what resources are retrieved when querying the API. + For example, a publishable API key can be associated with one or more sales channels. When it is passed in the header of a request to the List Product store API Route, + the sales channels are inferred from the key and only products associated with those sales channels are retrieved. + Admins can manage publishable API keys and their associated resources. Currently, only Sales Channels are supported as a resource. externalDocs: description: How to manage publishable API keys url: https://docs.medusajs.com/development/publishable-api-keys/admin/manage-publishable-api-keys - - name: Reservations - description: | - Reservations, provided by the [Inventory Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), are quantities of an item that are reserved, typically when an order is placed but not yet fulfilled. - Reservations can be associated with any resources, but commonly with line items of an order. - externalDocs: - description: How to manage item allocations in orders - url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-item-allocations-in-orders - name: Regions - description: | - Regions are different countries or geographical regions that the commerce store serves customers in. + description: > + Regions are different countries or geographical regions that the commerce + store serves customers in. + Admins can manage these regions, their providers, and more. externalDocs: description: How to manage regions url: https://docs.medusajs.com/modules/regions-and-currencies/admin/manage-regions + - name: Reservations + description: > + Reservations, provided by the [Inventory + Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), + are quantities of an item that are reserved, typically when an order is + placed but not yet fulfilled. + + Reservations can be associated with any resources, but commonly with line items of an order. + externalDocs: + description: How to manage item allocations in orders + url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-item-allocations-in-orders - name: Return Reasons - description: | - Return reasons are key-value pairs that are used to specify why an order return is being created. + description: > + Return reasons are key-value pairs that are used to specify why an order + return is being created. + Admins can manage available return reasons, and they can be used by both admins and customers when creating a return. externalDocs: description: How to manage return reasons url: https://docs.medusajs.com/modules/orders/admin/manage-returns#manage-return-reasons - name: Returns - description: | - A return can be created by a customer or an admin to return items in an order. + description: > + A return can be created by a customer or an admin to return items in an + order. + Admins can manage these returns and change their state. externalDocs: description: How to manage returns url: https://docs.medusajs.com/modules/orders/admin/manage-returns - name: Sales Channels - description: | - A sales channel indicates a channel where products can be sold in. For example, a webshop or a mobile app. + description: > + A sales channel indicates a channel where products can be sold in. For + example, a webshop or a mobile app. + Admins can manage sales channels and the products available in them. externalDocs: description: How to manage sales channels url: https://docs.medusajs.com/modules/sales-channels/admin/manage - name: Shipping Options - description: | - A shipping option is used to define the available shipping methods during checkout or when creating a return. + description: > + A shipping option is used to define the available shipping methods during + checkout or when creating a return. + Admins can create an unlimited number of shipping options, each associated with a shipping profile and fulfillment provider, among other resources. externalDocs: description: Shipping Option architecture url: https://docs.medusajs.com/modules/carts-and-checkout/shipping#shipping-option - name: Shipping Profiles - description: | - A shipping profile is used to group products that can be shipped in the same manner. + description: > + A shipping profile is used to group products that can be shipped in the + same manner. + They are created by the admin and they're not associated with a fulfillment provider. externalDocs: description: Shipping Profile architecture url: https://docs.medusajs.com/modules/carts-and-checkout/shipping#shipping-profile - name: Stock Locations - description: | - A stock location, provided by the [Stock Location module](https://docs.medusajs.com/modules/multiwarehouse/stock-location-module), indicates a physical address that stock-kept items, such as physical products, can be stored in. + description: > + A stock location, provided by the [Stock Location + module](https://docs.medusajs.com/modules/multiwarehouse/stock-location-module), + indicates a physical address that stock-kept items, such as physical + products, can be stored in. + An admin can create and manage available stock locations. externalDocs: description: How to manage stock locations. url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-stock-locations - name: Store - description: | - A store indicates the general configurations and details about the commerce store. By default, there's only one store in the Medusa backend. + description: > + A store indicates the general configurations and details about the + commerce store. By default, there's only one store in the Medusa backend. + Admins can manage the store and its details or configurations. - name: Swaps - description: | - A swap is created by a customer or an admin to exchange an item with a new one. + description: > + A swap is created by a customer or an admin to exchange an item with a new + one. + Creating a swap implicitely includes creating a return for the item being exchanged. externalDocs: description: How to manage swaps url: https://docs.medusajs.com/modules/orders/admin/manage-swaps - name: Tax Rates - description: | - Each region has at least a default tax rate. Admins can create and manage additional tax rates that can be applied for certain conditions, such as for specific product types. + description: > + Each region has at least a default tax rate. Admins can create and manage + additional tax rates that can be applied for certain conditions, such as + for specific product types. externalDocs: description: How to manage tax rates url: https://docs.medusajs.com/modules/taxes/admin/manage-tax-rates - name: Uploads - description: | - The upload API Routes are used to upload any type of resources. For example, they can be used to upload CSV files that are used to import products into the store. + description: > + The upload API Routes are used to upload any type of resources. For + example, they can be used to upload CSV files that are used to import + products into the store. externalDocs: description: How to upload CSV file when importing a product. url: https://docs.medusajs.com/modules/products/admin/import-products#1-upload-csv-file - name: Users - description: | - A store can have more than one user, each having the same privileges. Admins can manage users, their passwords, and more. + description: > + A store can have more than one user, each having the same privileges. + Admins can manage users, their passwords, and more. externalDocs: description: How to manage users url: https://docs.medusajs.com/modules/users/admin/manage-users servers: - url: http://localhost:9000 - url: https://api.medusa-commerce.com -paths: { } +paths: {} components: responses: default_error: @@ -245,9 +323,9 @@ components: schema: $ref: "#/components/schemas/Error" example: - code: "unknown_error" - message: "An unknown error occurred." - type: "unknown_error" + code: unknown_error + message: An unknown error occurred. + type: unknown_error invalid_state_error: description: Invalid State Error content: @@ -255,9 +333,10 @@ components: schema: $ref: "#/components/schemas/Error" example: - code: "unknown_error" - message: "The request conflicted with another request. You may retry the request with the provided Idempotency-Key." - type: "QueryRunnerAlreadyReleasedError" + code: unknown_error + message: The request conflicted with another request. You may retry the request + with the provided Idempotency-Key. + type: QueryRunnerAlreadyReleasedError invalid_request_error: description: Invalid Request Error content: @@ -265,9 +344,9 @@ components: schema: $ref: "#/components/schemas/Error" example: - code: "invalid_request_error" - message: "Discount with code TEST already exists." - type: "duplicate_error" + code: invalid_request_error + message: Discount with code TEST already exists. + type: duplicate_error not_found_error: description: Not Found Error content: @@ -275,8 +354,8 @@ components: schema: $ref: "#/components/schemas/Error" example: - message: "Entity with id 1 was not found" - type: "not_found" + message: Entity with id 1 was not found + type: not_found 400_error: description: Client Error or Multiple Errors content: @@ -308,7 +387,7 @@ components: default_error: $ref: "#/components/examples/default_error" unauthorized: - description: 'User is not authorized. Must log in first' + description: User is not authorized. Must log in first content: text/plain: schema: @@ -316,7 +395,7 @@ components: default: Unauthorized example: Unauthorized incorrect_credentials: - description: 'User does not exist or incorrect credentials' + description: User does not exist or incorrect credentials content: text/plain: schema: @@ -327,44 +406,45 @@ components: not_allowed_error: summary: Not Allowed Error value: - message: "Discount must be set to dynamic" - type: "not_allowed" + message: Discount must be set to dynamic + type: not_allowed invalid_data_error: summary: Invalid Data Error value: - message: "first_name must be a string" - type: "invalid_data" + message: first_name must be a string + type: invalid_data multiple_errors: summary: Multiple Errors value: - message: "Provided request body contains errors. Please check the data and retry the request" + message: Provided request body contains errors. Please check the data and retry + the request errors: - - message: "first_name must be a string" - type: "invalid_data" - - message: "Discount must be set to dynamic" - type: "not_allowed" + - message: first_name must be a string + type: invalid_data + - message: Discount must be set to dynamic + type: not_allowed database_error: summary: Database Error value: - code: "api_error" - message: "An error occured while hashing password" - type: "database_error" + code: api_error + message: An error occured while hashing password + type: database_error unexpected_state_error: summary: Unexpected State Error value: - message: "cart.total must be defined" - type: "unexpected_state" + message: cart.total must be defined + type: unexpected_state invalid_argument_error: summary: Invalid Argument Error value: - message: "cart.total must be defined" - type: "unexpected_state" + message: cart.total must be defined + type: unexpected_state default_error: summary: Default Error value: - code: "unknown_error" - message: "An unknown error occurred." - type: "unknown_error" + code: unknown_error + message: An unknown error occurred. + type: unknown_error securitySchemes: api_token: type: apiKey @@ -379,4 +459,4 @@ components: type: apiKey in: cookie name: connect.sid - x-displayName: Cookie Session ID \ No newline at end of file + x-displayName: Cookie Session ID diff --git a/packages/medusa/oas/store.oas.base.yaml b/docs-util/oas-output/base-v2/store.oas.base.yaml similarity index 100% rename from packages/medusa/oas/store.oas.base.yaml rename to docs-util/oas-output/base-v2/store.oas.base.yaml diff --git a/docs-util/oas-output/base/admin.oas.base.yaml b/docs-util/oas-output/base/admin.oas.base.yaml new file mode 100644 index 0000000000..27870b55e5 --- /dev/null +++ b/docs-util/oas-output/base/admin.oas.base.yaml @@ -0,0 +1,462 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Medusa Admin API + license: + name: MIT + url: https://github.com/medusajs/medusa/blob/master/LICENSE +tags: + - name: Apps Oauth + description: > + Some plugins may require to authenticate with third-party services and + store authentication details, such as the authentication token. To do + that, they can create an Oauth provider within the plugin that handles the + authentication. + + The Apps Oauth API Routes allows admins to manage and generate token for an app using its oauth provider. + - name: Auth + description: > + Authentication API Routes allow admin users to manage their session, such + as login or log out. + + When an admin user is logged in, the cookie header is set indicating the admin's login session. + externalDocs: + description: How to implement user profiles + url: https://docs.medusajs.com/modules/users/admin/manage-profile + - name: Batch Jobs + description: > + A batch job is a task that is performed by the Medusa backend + asynchronusly. For example, the Import Product feature is implemented + using batch jobs. + + Batch Job API Routes allow admins to manage the batch jobs and their state. + externalDocs: + description: How to import products + url: https://docs.medusajs.com/modules/products/admin/import-products + - name: Currencies + description: > + A store can use unlimited currencies, and each region must be associated + with at least one currency. + + Currencies are defined within the Medusa backend. Currency API Routes allow admins to list and update currencies. + externalDocs: + description: How to manage currencies + url: https://docs.medusajs.com/modules/regions-and-currencies/admin/manage-currencies + - name: Customer Groups + description: > + Customer Groups can be used to organize customers that share similar data + or attributes into dedicated groups. + + This can be useful for different purposes such as setting a different price for a specific customer group. + externalDocs: + description: How to manage customer groups + url: https://docs.medusajs.com/modules/customers/admin/manage-customer-groups + - name: Customers + description: > + Customers can either be created when they register through the Store APIs, + or created by the admin using the Admin APIs. + externalDocs: + description: How to manage customers + url: https://docs.medusajs.com/modules/customers/admin/manage-customers + - name: Discounts + description: > + Admins can create discounts with conditions and rules, providing them with + advanced settings for variety of cases. + + The Discount API Routes can be used to manage discounts, their conditions, resources, and more. + externalDocs: + description: How to manage discounts + url: https://docs.medusajs.com/modules/discounts/admin/manage-discounts + - name: Draft Orders + description: > + A draft order is an order created manually by the admin. It allows admins + to create orders without direct involvement from the customer. + externalDocs: + description: How to manage draft orders + url: https://docs.medusajs.com/modules/orders/admin/manage-draft-orders + - name: Gift Cards + description: > + Admins can create gift cards and send them directly to customers, + specifying options like their balance, region, and more. + + These gift cards are different than the saleable gift cards in a store, which are created and managed through Product API Routes. + externalDocs: + description: How to manage gift cards + url: https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-custom-gift-cards + - name: Inventory Items + description: > + Inventory items, provided by the [Inventory + Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), + can be used to manage the inventory of saleable items in your store. + externalDocs: + description: How to manage inventory items + url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-inventory-items + - name: Invites + description: > + An admin can invite new users to manage their team. This would allow new + users to authenticate as admins and perform admin functionalities. + externalDocs: + description: How to manage invites + url: https://docs.medusajs.com/modules/users/admin/manage-invites + - name: Notes + description: > + Notes are created by admins and can be associated with any resource. For + example, an admin can add a note to an order for additional details or + remarks. + - name: Notifications + description: > + Notifications are sent to customers to inform them of new updates. For + example, a notification can be sent to the customer when their order is + place or its state is updated. + + The notification's type, such as an email or SMS, is determined by the notification provider installed on the Medusa backend. + - name: Order Edits + description: > + An admin can edit an order to remove, add, or update an item's quantity. + When an admin edits an order, they're stored as an `OrderEdit`. + externalDocs: + description: How to edit an order + url: https://docs.medusajs.com/modules/orders/admin/edit-order + - name: Orders + description: > + Orders are purchases made by customers, typically through a storefront + using the Store API. Draft orders created by the admin are also + transformed to an Order once the payment is captured. + + Managing orders include managing fulfillment, payment, claims, reservations, and more. + externalDocs: + description: How to manage orders + url: https://docs.medusajs.com/modules/orders/admin/manage-orders + - name: Payment Collections + description: > + A payment collection is useful for managing additional payments, such as + for Order Edits, or installment payments. + - name: Payments + description: > + A payment can be related to an order, swap, return, or more. It can be + captured or refunded. + - name: Price Lists + description: > + A price list are special prices applied to products based on a set of + conditions, such as customer group. + externalDocs: + description: How to manage price lists + url: https://docs.medusajs.com/modules/price-lists/admin/manage-price-lists + - name: Product Categories + description: > + Products can be categoriezed into categories. A product can be added into + more than one category. + externalDocs: + description: How to manage product categories + url: https://docs.medusajs.com/modules/products/admin/manage-categories + - name: Product Collections + description: > + A product collection is used to organize products for different purposes + such as marketing or discount purposes. For example, you can create a + Summer Collection. + - name: Product Tags + description: > + Product tags are string values created when you create or update a product + with a new tag. + + Products can have more than one tag, and products can share tags. This allows admins to associate products to similar tags that can be used to filter products. + - name: Product Types + description: > + Product types are string values created when you create or update a + product with a new type. + + Products can have one type, and products can share types. This allows admins to associate products with a type that can be used to filter products. + - name: Product Variants + description: > + Product variants are the actual salable item in your store. Each variant + is a combination of the different option values available on the product. + + Product variants can be managed through the Products API Routes. + externalDocs: + description: How to manage product variants + url: https://docs.medusajs.com/modules/products/admin/manage-products#manage-product-variants + - name: Products + description: > + Products are saleable items in a store. This also includes [saleable gift + cards](https://docs.medusajs.com/modules/gift-cards/admin/manage-gift-cards#manage-gift-card-product) + in a store. + externalDocs: + description: How to manage products + url: https://docs.medusajs.com/modules/products/admin/manage-products + - name: Publishable API Keys + description: > + Publishable API Keys can be used to scope Store API calls with an API key, + determining what resources are retrieved when querying the API. + + For example, a publishable API key can be associated with one or more sales channels. When it is passed in the header of a request to the List Product store API Route, + + the sales channels are inferred from the key and only products associated with those sales channels are retrieved. + + Admins can manage publishable API keys and their associated resources. Currently, only Sales Channels are supported as a resource. + externalDocs: + description: How to manage publishable API keys + url: https://docs.medusajs.com/development/publishable-api-keys/admin/manage-publishable-api-keys + - name: Regions + description: > + Regions are different countries or geographical regions that the commerce + store serves customers in. + + Admins can manage these regions, their providers, and more. + externalDocs: + description: How to manage regions + url: https://docs.medusajs.com/modules/regions-and-currencies/admin/manage-regions + - name: Reservations + description: > + Reservations, provided by the [Inventory + Module](https://docs.medusajs.com/modules/multiwarehouse/inventory-module), + are quantities of an item that are reserved, typically when an order is + placed but not yet fulfilled. + + Reservations can be associated with any resources, but commonly with line items of an order. + externalDocs: + description: How to manage item allocations in orders + url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-item-allocations-in-orders + - name: Return Reasons + description: > + Return reasons are key-value pairs that are used to specify why an order + return is being created. + + Admins can manage available return reasons, and they can be used by both admins and customers when creating a return. + externalDocs: + description: How to manage return reasons + url: https://docs.medusajs.com/modules/orders/admin/manage-returns#manage-return-reasons + - name: Returns + description: > + A return can be created by a customer or an admin to return items in an + order. + + Admins can manage these returns and change their state. + externalDocs: + description: How to manage returns + url: https://docs.medusajs.com/modules/orders/admin/manage-returns + - name: Sales Channels + description: > + A sales channel indicates a channel where products can be sold in. For + example, a webshop or a mobile app. + + Admins can manage sales channels and the products available in them. + externalDocs: + description: How to manage sales channels + url: https://docs.medusajs.com/modules/sales-channels/admin/manage + - name: Shipping Options + description: > + A shipping option is used to define the available shipping methods during + checkout or when creating a return. + + Admins can create an unlimited number of shipping options, each associated with a shipping profile and fulfillment provider, among other resources. + externalDocs: + description: Shipping Option architecture + url: https://docs.medusajs.com/modules/carts-and-checkout/shipping#shipping-option + - name: Shipping Profiles + description: > + A shipping profile is used to group products that can be shipped in the + same manner. + + They are created by the admin and they're not associated with a fulfillment provider. + externalDocs: + description: Shipping Profile architecture + url: https://docs.medusajs.com/modules/carts-and-checkout/shipping#shipping-profile + - name: Stock Locations + description: > + A stock location, provided by the [Stock Location + module](https://docs.medusajs.com/modules/multiwarehouse/stock-location-module), + indicates a physical address that stock-kept items, such as physical + products, can be stored in. + + An admin can create and manage available stock locations. + externalDocs: + description: How to manage stock locations. + url: https://docs.medusajs.com/modules/multiwarehouse/admin/manage-stock-locations + - name: Store + description: > + A store indicates the general configurations and details about the + commerce store. By default, there's only one store in the Medusa backend. + + Admins can manage the store and its details or configurations. + - name: Swaps + description: > + A swap is created by a customer or an admin to exchange an item with a new + one. + + Creating a swap implicitely includes creating a return for the item being exchanged. + externalDocs: + description: How to manage swaps + url: https://docs.medusajs.com/modules/orders/admin/manage-swaps + - name: Tax Rates + description: > + Each region has at least a default tax rate. Admins can create and manage + additional tax rates that can be applied for certain conditions, such as + for specific product types. + externalDocs: + description: How to manage tax rates + url: https://docs.medusajs.com/modules/taxes/admin/manage-tax-rates + - name: Uploads + description: > + The upload API Routes are used to upload any type of resources. For + example, they can be used to upload CSV files that are used to import + products into the store. + externalDocs: + description: How to upload CSV file when importing a product. + url: https://docs.medusajs.com/modules/products/admin/import-products#1-upload-csv-file + - name: Users + description: > + A store can have more than one user, each having the same privileges. + Admins can manage users, their passwords, and more. + externalDocs: + description: How to manage users + url: https://docs.medusajs.com/modules/users/admin/manage-users +servers: + - url: http://localhost:9000 + - url: https://api.medusa-commerce.com +paths: {} +components: + responses: + default_error: + description: Default Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: unknown_error + message: An unknown error occurred. + type: unknown_error + invalid_state_error: + description: Invalid State Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: unknown_error + message: The request conflicted with another request. You may retry the request + with the provided Idempotency-Key. + type: QueryRunnerAlreadyReleasedError + invalid_request_error: + description: Invalid Request Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: invalid_request_error + message: Discount with code TEST already exists. + type: duplicate_error + not_found_error: + description: Not Found Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + message: Entity with id 1 was not found + type: not_found + 400_error: + description: Client Error or Multiple Errors + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Error" + - $ref: "#/components/schemas/MultipleErrors" + examples: + not_allowed: + $ref: "#/components/examples/not_allowed_error" + invalid_data: + $ref: "#/components/examples/invalid_data_error" + MultipleErrors: + $ref: "#/components/examples/multiple_errors" + 500_error: + description: Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + database: + $ref: "#/components/examples/database_error" + unexpected_state: + $ref: "#/components/examples/unexpected_state_error" + invalid_argument: + $ref: "#/components/examples/invalid_argument_error" + default_error: + $ref: "#/components/examples/default_error" + unauthorized: + description: User is not authorized. Must log in first + content: + text/plain: + schema: + type: string + default: Unauthorized + example: Unauthorized + incorrect_credentials: + description: User does not exist or incorrect credentials + content: + text/plain: + schema: + type: string + default: Unauthorized + example: Unauthorized + examples: + not_allowed_error: + summary: Not Allowed Error + value: + message: Discount must be set to dynamic + type: not_allowed + invalid_data_error: + summary: Invalid Data Error + value: + message: first_name must be a string + type: invalid_data + multiple_errors: + summary: Multiple Errors + value: + message: Provided request body contains errors. Please check the data and retry + the request + errors: + - message: first_name must be a string + type: invalid_data + - message: Discount must be set to dynamic + type: not_allowed + database_error: + summary: Database Error + value: + code: api_error + message: An error occured while hashing password + type: database_error + unexpected_state_error: + summary: Unexpected State Error + value: + message: cart.total must be defined + type: unexpected_state + invalid_argument_error: + summary: Invalid Argument Error + value: + message: cart.total must be defined + type: unexpected_state + default_error: + summary: Default Error + value: + code: unknown_error + message: An unknown error occurred. + type: unknown_error + securitySchemes: + api_token: + type: apiKey + x-displayName: API Token + in: header + name: x-medusa-access-token + jwt_token: + type: http + x-displayName: JWT Token + scheme: bearer + cookie_auth: + type: apiKey + in: cookie + name: connect.sid + x-displayName: Cookie Session ID diff --git a/docs-util/oas-output/base/store.oas.base.yaml b/docs-util/oas-output/base/store.oas.base.yaml new file mode 100644 index 0000000000..fa2ab873f3 --- /dev/null +++ b/docs-util/oas-output/base/store.oas.base.yaml @@ -0,0 +1,287 @@ +openapi: 3.0.0 +info: + version: 1.0.0 + title: Medusa Storefront API + license: + name: MIT + url: https://github.com/medusajs/medusa/blob/master/LICENSE +tags: + - name: Auth + description: | + Authentication API Routes allow you to manage a customer's session, such as login or log out. + You can send authenticated requests for a customer either using the Cookie header or using the JWT Token. + externalDocs: + description: How to implement customer profiles in your storefront + url: https://docs.medusajs.com/modules/customers/storefront/implement-customer-profiles + - name: Carts + description: | + A cart is a virtual shopping bag that customers can use to add items they want to purchase. + A cart is then used to checkout and place an order. + externalDocs: + description: How to implement cart functionality in your storefront + url: https://docs.medusajs.com/modules/carts-and-checkout/storefront/implement-cart + - name: Customers + description: | + A customer can register and manage their information such as addresses, orders, payment methods, and more. + externalDocs: + description: How to implement customer profiles in your storefront + url: https://docs.medusajs.com/modules/customers/storefront/implement-customer-profiles + - name: Gift Cards + description: | + Customers can use gift cards during checkout to deduct the gift card's balance from the checkout total. + The Gift Card API Routes allow retrieving a gift card's details by its code. A gift card can be applied to a cart using the Carts API Routes. + externalDocs: + description: How to use gift cards in a storefront + url: https://docs.medusajs.com/modules/gift-cards/storefront/use-gift-cards + - name: Orders + description: | + Orders are purchases made by customers, typically through a storefront. + Orders are placed and created using the Carts API Routes. The Orders API Routes allow retrieving and claiming orders. + externalDocs: + description: How to retrieve order details in a storefront + url: https://docs.medusajs.com/modules/orders/storefront/retrieve-order-details + - name: Order Edits + description: | + Order edits are changes made to items in an order such as adding, updating their quantity, or deleting them. Order edits are created by the admin. + A customer can review order edit requests created by an admin and confirm or decline them. + externalDocs: + description: How to handle order edits in a storefront + url: https://docs.medusajs.com/modules/orders/storefront/handle-order-edits + - name: Payment Collections + description: | + A payment collection is useful for managing additional payments, such as for Order Edits, or installment payments. + - name: Products + description: | + Products are saleable items in a store. This also includes [saleable gift cards](https://docs.medusajs.com/modules/gift-cards/storefront/use-gift-cards) in a store. + Using these API Routes, you can filter products by categories, collections, sales channels, and more. + externalDocs: + description: How to show products in a storefront + url: https://docs.medusajs.com/modules/products/storefront/show-products + - name: Product Variants + description: | + Product variants are the actual salable item in your store. Each variant is a combination of the different option values available on the product. + - name: Product Categories + description: | + Products can be categoriezed into categories. A product can be associated more than one category. + Using these API Routes, you can list or retrieve a category's details and products. + externalDocs: + description: How to use product categories in a storefront + url: https://docs.medusajs.com/modules/products/storefront/use-categories + - name: Product Collections + description: | + A product collection is used to organize products for different purposes such as marketing or discount purposes. For example, you can create a Summer Collection. + Using these API Routes, you can list or retrieve a collection's details and products. + - name: Product Tags + description: | + Product tags are string values that can be used to filter products by. + Products can have more than one tag, and products can share tags. + - name: Product Types + description: | + Product types are string values that can be used to filter products by. + Products can have more than one tag, and products can share types. + - name: Regions + description: | + Regions are different countries or geographical regions that the commerce store serves customers in. + Customers can choose what region they're in, which can be used to change the prices shown based on the region and its currency. + externalDocs: + description: How to use regions in a storefront + url: https://docs.medusajs.com/modules/regions-and-currencies/storefront/use-regions + - name: Returns + description: | + A return can be created by a customer to return items in an order. + externalDocs: + description: How to create a return in a storefront + url: https://docs.medusajs.com/modules/orders/storefront/create-return + - name: Return Reasons + description: | + Return reasons are key-value pairs that are used to specify why an order return is being created. + - name: Shipping Options + description: | + A shipping option is used to define the available shipping methods during checkout or when creating a return. + externalDocs: + description: Shipping Option architecture + url: https://docs.medusajs.com/modules/carts-and-checkout/shipping#shipping-option + - name: Swaps + description: | + A swap is created by a customer or an admin to exchange an item with a new one. + Creating a swap implicitely includes creating a return for the item being exchanged. + externalDocs: + description: How to create a swap in a storefront + url: https://docs.medusajs.com/modules/orders/storefront/create-swap +servers: + - url: http://localhost:9000 + - url: https://api.medusa-commerce.com +paths: { } +components: + responses: + default_error: + description: Default Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: "unknown_error" + message: "An unknown error occurred." + type: "unknown_error" + invalid_state_error: + description: Invalid State Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: "unknown_error" + message: "The request conflicted with another request. You may retry the request with the provided Idempotency-Key." + type: "QueryRunnerAlreadyReleasedError" + invalid_request_error: + description: Invalid Request Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + code: "invalid_request_error" + message: "Discount with code TEST already exists." + type: "duplicate_error" + not_found_error: + description: Not Found Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + example: + message: "Entity with id 1 was not found" + type: "not_found" + 400_error: + description: Client Error or Multiple Errors + content: + application/json: + schema: + oneOf: + - $ref: "#/components/schemas/Error" + - $ref: "#/components/schemas/MultipleErrors" + examples: + not_allowed: + $ref: "#/components/examples/not_allowed_error" + invalid_data: + $ref: "#/components/examples/invalid_data_error" + MultipleErrors: + $ref: "#/components/examples/multiple_errors" + 500_error: + description: Server Error + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + examples: + database: + $ref: "#/components/examples/database_error" + unexpected_state: + $ref: "#/components/examples/unexpected_state_error" + invalid_argument: + $ref: "#/components/examples/invalid_argument_error" + default_error: + $ref: "#/components/examples/default_error" + unauthorized: + description: 'User is not authorized. Must log in first' + content: + text/plain: + schema: + type: string + default: Unauthorized + example: Unauthorized + incorrect_credentials: + description: 'User does not exist or incorrect credentials' + content: + text/plain: + schema: + type: string + default: Unauthorized + example: Unauthorized + examples: + not_allowed_error: + summary: Not Allowed Error + value: + message: "Discount must be set to dynamic" + type: "not_allowed" + invalid_data_error: + summary: Invalid Data Error + value: + message: "first_name must be a string" + type: "invalid_data" + multiple_errors: + summary: Multiple Errors + value: + message: "Provided request body contains errors. Please check the data and retry the request" + errors: + - message: "first_name must be a string" + type: "invalid_data" + - message: "Discount must be set to dynamic" + type: "not_allowed" + database_error: + summary: Database Error + value: + code: "api_error" + message: "An error occured while hashing password" + type: "database_error" + unexpected_state_error: + summary: Unexpected State Error + value: + message: "cart.total must be defined" + type: "unexpected_state" + invalid_argument_error: + summary: Invalid Argument Error + value: + message: "cart.total must be defined" + type: "unexpected_state" + default_error: + summary: Default Error + value: + code: "unknown_error" + message: "An unknown error occurred." + type: "unknown_error" + securitySchemes: + jwt_token: + type: http + x-displayName: JWT Token + scheme: bearer + cookie_auth: + type: apiKey + x-displayName: Cookie Session ID + in: cookie + name: connect.sid + description: | + Use a cookie session to send authenticated requests. + + ### How to Obtain the Cookie Session + + If you're sending requests through a browser, using JS Client, or using tools like Postman, the cookie session should be automatically set when the customer is logged in. + + If you're sending requests using cURL, you must set the Session ID in the cookie manually. + + To do that, send a request to [authenticate the customer](#tag/Auth/operation/PostAuth) and pass the cURL option `-v`: + + ```bash + curl -v --location --request POST 'https://medusa-url.com/store/auth' \ + --header 'Content-Type: application/json' \ + --data-raw '{ + "email": "user@example.com", + "password": "supersecret" + }' + ``` + + The headers will be logged in the terminal as well as the response. You should find in the headers a Cookie header similar to this: + + ```bash + Set-Cookie: connect.sid=s%3A2Bu8BkaP9JUfHu9rG59G16Ma0QZf6Gj1.WT549XqX37PN8n0OecqnMCq798eLjZC5IT7yiDCBHPM; + ``` + + Copy the value after `connect.sid` (without the `;` at the end) and pass it as a cookie in subsequent requests as the following: + + ```bash + curl --location --request GET 'https://medusa-url.com/store/customers/me/orders' \ + --header 'Cookie: connect.sid={sid}' + ``` + + Where `{sid}` is the value of `connect.sid` that you copied. \ No newline at end of file diff --git a/docs-util/packages/docblock-generator/README.md b/docs-util/packages/docblock-generator/README.md index b2eb9d9e5c..d02e57995f 100644 --- a/docs-util/packages/docblock-generator/README.md +++ b/docs-util/packages/docblock-generator/README.md @@ -1,11 +1,12 @@ # docblock-generator -A CLI tool that can be used to generate TSDoc docblocks for TypeScript/JavaScript files under the `packages` directory of the main monorepo. +A CLI tool that can be used to generate TSDoc docblocks and OAS for TypeScript/JavaScript files under the `packages` directory of the main monorepo. ## Prerequisites 1. Run the `yarn` command to install dependencies. 2. Copy the `.env.sample` to `.env` and change the `MONOREPO_ROOT_PATH` variable to the absolute path to the monorepo root. +3. Run the `yarn build` command to build source files. --- @@ -36,3 +37,55 @@ yarn start run:commit ``` Where `` is the SHA of the commit. For example, `e28fa7fbdf45c5b1fa19848db731132a0bf1757d`. + +### Generate for a release + +Run the following command to run the tool on commits since the latest release. + +```bash +yarn start run:release +``` + +### Clean OAS + +Run the following command to clean up the OAS output files and remove any routes that no longer exist: + +```bash +yarn start clean:oas +``` + +This command will also remove tags and schemas not used. + +--- + +## How it Works + +### Generating OAS + +If a node is an API route, it generates OAS comments rather than TSDoc comments. The OAS comments are generated and placed in new/existing files under the `docs-util/oas-output/operations` directory. + +### Generating TSDoc Docblocks + +If a note isn't an API Route and it complies with the specified conditions, TSDoc docblocks are generated for it. + +Files under the `packages/medusa/src/api` or `api-v2` directories are considered incompatible, so any files under these directories won't have TSDoc docblocks generated for them. + +--- + +## Common Options + +This section includes options that you can pass to any of the mentioned commands. + +### --generate-examples + +If this option is passed, the tool will try to generate OAS examples. Currently, it will only try to generate JS Client examples. + +cURL examples are always generated regardless of this option. + +### --type + +You can use this option to specify the type of docs to generate. Possible values are: + +- `all`: (default) Generate all doc types. +- `docs`: Generate only TSDoc docblocks. +- `oas`: Generate only OAS docblocks/files. diff --git a/docs-util/packages/docblock-generator/package.json b/docs-util/packages/docblock-generator/package.json index 00e59c7847..e6f565a651 100644 --- a/docs-util/packages/docblock-generator/package.json +++ b/docs-util/packages/docblock-generator/package.json @@ -18,14 +18,19 @@ "workflow-diagrams-generator": "dist/index.js" }, "dependencies": { + "@faker-js/faker": "^8.4.0", "@octokit/core": "^5.0.2", "commander": "^11.1.0", "dotenv": "^16.3.1", "eslint": "^8.56.0", "minimatch": "^9.0.3", + "openapi-types": "^12.1.3", + "pluralize": "^8.0.0", + "prettier": "^3.2.4", "ts-node": "^10.9.1", "typescript": "5.2", - "utils": "*" + "utils": "*", + "yaml": "^2.3.4" }, "devDependencies": { "@types/node": "^20.9.4" diff --git a/docs-util/packages/docblock-generator/src/classes/examples/oas.ts b/docs-util/packages/docblock-generator/src/classes/examples/oas.ts new file mode 100644 index 0000000000..21643b7c3f --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/examples/oas.ts @@ -0,0 +1,383 @@ +import { faker } from "@faker-js/faker" +import { OpenAPIV3 } from "openapi-types" +import { API_ROUTE_PARAM_REGEX, OasArea } from "../kinds/oas.js" +import { + capitalize, + kebabToCamel, + wordsToCamel, + wordsToKebab, +} from "../../utils/str-formatting.js" +import { CodeSample } from "../../types/index.js" + +type CodeSampleData = Omit + +/** + * This class generates examples for OAS. + */ +class OasExamplesGenerator { + static JSCLIENT_CODESAMPLE_DATA: CodeSampleData = { + lang: "JavaScript", + label: "JS Client", + } + static CURL_CODESAMPLE_DATA: CodeSampleData = { + lang: "Shell", + label: "cURL", + } + static MEDUSAREACT_CODESAMPLE_DATA: CodeSampleData = { + lang: "tsx", + label: "Medusa React", + } + + /** + * Generate JS client example for an OAS operation. + * + * @param param0 - The operation's details + * @returns The JS client example. + */ + generateJSClientExample({ + area, + tag, + oasPath, + httpMethod, + isAdminAuthenticated, + isStoreAuthenticated, + parameters, + requestBody, + responseBody, + }: { + /** + * The area of the operation. + */ + area: OasArea + /** + * The tag this operation belongs to. + */ + tag: string + /** + * The API route's path. + */ + oasPath: string + /** + * The http method of the operation. + */ + httpMethod: string + /** + * Whether the operation requires admin authentication. + */ + isAdminAuthenticated?: boolean + /** + * Whether the operation requires customer authentication. + */ + isStoreAuthenticated?: boolean + /** + * The path parameters that can be sent in the request, if any. + */ + parameters?: OpenAPIV3.ParameterObject[] + /** + * The request body's schema, if any. + */ + requestBody?: OpenAPIV3.SchemaObject + /** + * The response body's schema, if any. + */ + responseBody?: OpenAPIV3.SchemaObject + }) { + const exampleArr = [ + `import Medusa from "@medusajs/medusa-js"`, + `const medusa = new Medusa({ baseUrl: MEDUSA_BACKEND_URL, maxRetries: 3 })`, + ] + + if (isAdminAuthenticated) { + exampleArr.push(`// must be previously logged in or use api token`) + } else if (isStoreAuthenticated) { + exampleArr.push(`// must be previously logged in.`) + } + + // infer JS method name + // reset regex manually + API_ROUTE_PARAM_REGEX.lastIndex = 0 + const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath) + let jsMethod = `{methodName}` + if (isForSingleEntity) { + const splitOasPath = oasPath + .replaceAll(API_ROUTE_PARAM_REGEX, "") + .replace(/\/(batch)*$/, "") + .split("/") + const isBulk = oasPath.endsWith("/batch") + const isOperationOnDifferentEntity = + wordsToKebab(tag) !== splitOasPath[splitOasPath.length - 1] + if (isBulk || isOperationOnDifferentEntity) { + const endingEntityName = capitalize( + isBulk && + API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1]) + ? wordsToCamel(tag) + : kebabToCamel(splitOasPath[splitOasPath.length - 1]) + ) + + jsMethod = + httpMethod === "get" + ? `list${endingEntityName}` + : httpMethod === "post" + ? `add${endingEntityName}` + : `remove${endingEntityName}` + } else { + jsMethod = + httpMethod === "get" + ? "retrieve" + : httpMethod === "post" + ? "update" + : "delete" + } + } else { + jsMethod = + httpMethod === "get" + ? "list" + : httpMethod === "post" + ? "create" + : "delete" + } + + // collect the path/request parameters to be passed to the request. + const parametersArr: string[] = + parameters?.map((parameter) => parameter.name) || [] + const requestData = requestBody + ? this.getSchemaRequiredData(requestBody) + : {} + + // assemble the method-call line of format `medusa.{admin?}.{methodName}({...parameters,} {requestBodyDataObj})` + exampleArr.push( + `medusa${area === "admin" ? `.${area}` : ""}.${wordsToCamel( + tag + )}.${jsMethod}(${parametersArr.join(", ")}${ + Object.keys(requestData).length + ? `${parametersArr.length ? ", " : ""}${JSON.stringify( + requestData, + undefined, + 2 + )}` + : "" + })` + ) + + // assemble then lines with response data, if any + const responseData = responseBody + ? this.getSchemaRequiredData(responseBody) + : {} + const responseRequiredItems = Object.keys(responseData) + const responseRequiredItemsStr = responseRequiredItems.length + ? `{ ${responseRequiredItems.join(", ")} }` + : "" + + exampleArr.push( + `.then((${responseRequiredItemsStr}) => {\n\t\t${ + responseRequiredItemsStr.length + ? `console.log(${responseRequiredItemsStr})` + : "// Success" + }\n})` + ) + + return exampleArr.join("\n") + } + + /** + * Generate cURL examples for an OAS operation. + * + * @param param0 - The operation's details. + * @returns The cURL example. + */ + generateCurlExample({ + method, + path, + isAdminAuthenticated, + isStoreAuthenticated, + requestSchema, + }: { + /** + * The HTTP method. + */ + method: string + /** + * The API Route's path. + */ + path: string + /** + * Whether the route requires admin authentication. + */ + isAdminAuthenticated?: boolean + /** + * Whether the route requires customer authentication. + */ + isStoreAuthenticated?: boolean + /** + * The schema of the request body, if any. + */ + requestSchema?: OpenAPIV3.SchemaObject + }): string { + const exampleArr = [ + `curl${ + method.toLowerCase() !== "get" ? ` -X ${method.toUpperCase()}` : "" + } '{backend_url}${path}'`, + ] + + if (isAdminAuthenticated) { + exampleArr.push(`-H 'x-medusa-access-token: {api_token}'`) + } else if (isStoreAuthenticated) { + exampleArr.push(`-H 'Authorization: Bearer {access_token}'`) + } + + if (requestSchema) { + const requestData = this.getSchemaRequiredData(requestSchema) + + if (Object.keys(requestData).length > 0) { + exampleArr.push(`-H 'Content-Type: application/json'`) + exampleArr.push( + `--data-raw '${JSON.stringify(requestData, undefined, 2)}'` + ) + } + } + + return exampleArr.join(` \\\n`) + } + + /** + * Retrieves data object from a schema object. Only retrieves the required fields. + * + * @param schema - The schema to retrieve its required data object. + * @returns An object of required data and their fake values. + */ + getSchemaRequiredData( + schema: OpenAPIV3.SchemaObject + ): Record { + const data: Record = {} + + if (schema.required?.length && schema.properties) { + schema.required.forEach((propertyName) => { + // extract property and its type + const property = schema.properties![ + propertyName + ] as OpenAPIV3.SchemaObject + let value: unknown + if (property.type === "object") { + const typedValue: Record = {} + // get the fake value of every property in the object + if (property.properties) { + Object.entries(property.properties).forEach( + ([childName, childProp]) => { + const typedChildProp = childProp as OpenAPIV3.SchemaObject + if (!typedChildProp.type) { + return + } + // if the property is an object, get its data object + // otherwise, get its fake value + typedValue[childName] = + typedChildProp.type === "object" + ? this.getSchemaRequiredData( + typedChildProp as OpenAPIV3.SchemaObject + ) + : this.getFakeValue({ + name: childName, + type: typedChildProp.type, + format: typedChildProp.format, + }) + } + ) + } + + value = typedValue + } else if (property.type === "array") { + // if the type of the array's items is an object, retrieve + // its data object. Otherwise, retrieve its fake value. + const propertyItems = property.items as OpenAPIV3.SchemaObject + if (!propertyItems.type) { + value = [] + } else { + value = [ + propertyItems.type === "object" + ? this.getSchemaRequiredData( + property.items as OpenAPIV3.SchemaObject + ) + : this.getFakeValue({ + name: propertyName, + type: propertyItems.type, + format: propertyItems.format, + }), + ] + } + } else if (property.type) { + // retrieve fake value for all other types + value = this.getFakeValue({ + name: propertyName, + type: property.type, + format: property.format, + }) + } + + if (value !== undefined) { + data[propertyName] = value + } + }) + } + + return data + } + + /** + * Retrieve the fake value of a property. The value is used in examples. + * + * @param param0 - The property's details + * @returns The fake value + */ + getFakeValue({ + name, + type, + format, + }: { + /** + * The name of the property. It can help when generating the fake value. + * For example, if the name is `id`, the fake value generated will be of the format `id_`. + */ + name: string + /** + * The type of the property. + */ + type: OpenAPIV3.NonArraySchemaObjectType | "array" + /** + * The OAS format of the property. For example, `date-time`. + */ + format?: string + }): unknown { + let value: unknown + + switch (true) { + case type === "string" && format === "date-time": + value = faker.date.future().toISOString() + break + case type === "boolean": + value = faker.datatype.boolean() + break + case type === "integer" || type === "number": + value = faker.number.int() + break + case type === "array": + value = [] + break + case type === "string": + value = faker.helpers + .mustache(`{{${name}}}`, { + id: () => + `id_${faker.string.alphanumeric({ + length: { min: 10, max: 20 }, + })}`, + name: () => faker.person.firstName(), + email: () => faker.internet.email(), + password: () => faker.internet.password({ length: 8 }), + currency: () => faker.finance.currencyCode(), + }) + .replace(`{{${name}}}`, "{value}") + } + + return value !== undefined ? value : "{value}" + } +} + +export default OasExamplesGenerator diff --git a/docs-util/packages/docblock-generator/src/classes/docblock-generator.ts b/docs-util/packages/docblock-generator/src/classes/generators/docblock.ts similarity index 52% rename from docs-util/packages/docblock-generator/src/classes/docblock-generator.ts rename to docs-util/packages/docblock-generator/src/classes/generators/docblock.ts index 4214267c82..13ae16ee11 100644 --- a/docs-util/packages/docblock-generator/src/classes/docblock-generator.ts +++ b/docs-util/packages/docblock-generator/src/classes/generators/docblock.ts @@ -1,54 +1,35 @@ /* eslint-disable no-case-declarations */ import ts from "typescript" -import Formatter from "./formatter.js" -import KindsRegistry from "./kinds/registry.js" -import nodeHasComments from "../utils/node-has-comments.js" - -export type Options = { - paths: string[] - dryRun?: boolean -} +import { GeneratorEvent } from "../helpers/generator-event-manager.js" +import AbstractGenerator from "./index.js" +import { minimatch } from "minimatch" /** * A class used to generate docblock for one or multiple file paths. */ -class DocblockGenerator { - protected options: Options - protected program?: ts.Program - protected checker?: ts.TypeChecker - protected formatter: Formatter - protected kindsRegistry?: KindsRegistry - - constructor(options: Options) { - this.options = options - this.formatter = new Formatter() - } - +class DocblockGenerator extends AbstractGenerator { /** - * Generate the docblock for the paths specified in the {@link options} class property. + * Generate docblocks for the files in the `options`. */ async run() { - this.program = ts.createProgram(this.options.paths, {}) - - this.checker = this.program.getTypeChecker() - - this.kindsRegistry = new KindsRegistry(this.checker) + this.init() const printer = ts.createPrinter({ removeComments: false, }) await Promise.all( - this.program.getSourceFiles().map(async (file) => { + this.program!.getSourceFiles().map(async (file) => { // Ignore .d.ts files if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) { return } - console.log(`Generating for ${file.fileName}...`) + console.log(`[Docblock] Generating for ${file.fileName}...`) let fileContent = file.getFullText() let fileComments: string = "" + const commentsToRemove: string[] = [] const documentChild = (node: ts.Node, topLevel = false) => { const isSourceFile = ts.isSourceFile(node) @@ -56,7 +37,7 @@ class DocblockGenerator { const nodeKindGenerator = this.kindsRegistry?.getKindGenerator(node) let docComment: string | undefined - if (nodeKindGenerator && this.canDocumentNode(node)) { + if (nodeKindGenerator?.canDocumentNode(node)) { docComment = nodeKindGenerator.getDocBlock(node) if (docComment.length) { if (isSourceFile) { @@ -92,6 +73,9 @@ class DocblockGenerator { documentChild(file, true) if (!this.options.dryRun) { + commentsToRemove.forEach((commentToRemove) => { + fileContent = fileContent.replace(commentToRemove, "") + }) ts.sys.writeFile( file.fileName, this.formatter.addCommentsToSourceFile( @@ -101,44 +85,30 @@ class DocblockGenerator { ) } - console.log(`Finished generating docblock for ${file.fileName}.`) + console.log( + `[Docblock] Finished generating docblock for ${file.fileName}.` + ) }) ) + this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT) this.reset() } /** - * Checks whether a file is included in the specified files. + * Checks whether the specified file path is included in the program + * and isn't an API file. * - * @param {string} fileName - The file to check for. - * @returns {boolean} Whether the file can have docblocks generated for it. + * @param fileName - The file path to check + * @returns Whether the docblock generator can run on this file. */ isFileIncluded(fileName: string): boolean { - return this.options.paths.some((path) => path.includes(fileName)) - } - - /** - * Checks whether a node can be documented. - * - * @privateRemark - * I'm leaving this method in case other conditions arise for a node to be documented. - * Otherwise, we can directly use the {@link nodeHasComments} function. - * - * @param {ts.Node} node - The node to check for. - * @returns {boolean} Whether the node can be documented. - */ - canDocumentNode(node: ts.Node): boolean { - // check if node already has docblock - return !nodeHasComments(node) - } - - /** - * Reset the generator's properties for new usage. - */ - reset() { - this.program = undefined - this.checker = undefined + return ( + super.isFileIncluded(fileName) && + !minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", { + matchBase: true, + }) + ) } } diff --git a/docs-util/packages/docblock-generator/src/classes/generators/index.ts b/docs-util/packages/docblock-generator/src/classes/generators/index.ts new file mode 100644 index 0000000000..01c46bdc59 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/generators/index.ts @@ -0,0 +1,110 @@ +import ts from "typescript" +import Formatter from "../helpers/formatter.js" +import KindsRegistry from "../kinds/registry.js" +import GeneratorEventManager from "../helpers/generator-event-manager.js" +import { CommonCliOptions } from "../../types/index.js" +import { existsSync, readdirSync, statSync } from "node:fs" +import path from "node:path" + +export type Options = { + paths: string[] + dryRun?: boolean +} & Pick + +abstract class AbstractGenerator { + protected options: Options + protected program?: ts.Program + protected checker?: ts.TypeChecker + protected formatter: Formatter + protected kindsRegistry?: KindsRegistry + protected generatorEventManager: GeneratorEventManager + + constructor(options: Options) { + this.options = options + this.formatter = new Formatter() + this.generatorEventManager = new GeneratorEventManager() + } + + init() { + const files: string[] = [] + + this.options.paths.forEach((optionPath) => { + if (!existsSync(optionPath)) { + return + } + + if (!statSync(optionPath).isDirectory()) { + files.push(optionPath) + return + } + + // read files recursively from directory + files.push( + ...readdirSync(optionPath, { + recursive: true, + encoding: "utf-8", + }) + .map((filePath) => path.join(optionPath, filePath)) + .filter((filePath) => !statSync(filePath).isDirectory()) + ) + }) + + this.program = ts.createProgram(files, {}) + + this.checker = this.program.getTypeChecker() + + const { generateExamples } = this.options + + this.kindsRegistry = new KindsRegistry({ + checker: this.checker, + generatorEventManager: this.generatorEventManager, + additionalOptions: { + generateExamples, + }, + }) + } + + /** + * Generate the docblock for the paths specified in the {@link options} class property. + */ + abstract run(): void + + /** + * Checks whether a file is included in the specified files. + * + * @param {string} fileName - The file to check for. + * @returns {boolean} Whether the file can have docblocks generated for it. + */ + isFileIncluded(fileName: string): boolean { + const baseFilePath = this.getBasePath(fileName) + return this.options.paths.some((path) => + baseFilePath.startsWith(this.getBasePath(path)) + ) + } + + /** + * Retrieve the pathname of a file without the relative part before `packages/` + * + * @param fileName - The file name/path + * @returns The path without the relative part. + */ + getBasePath(fileName: string) { + let basePath = fileName + const packageIndex = fileName.indexOf("packages/") + if (packageIndex) { + basePath = basePath.substring(packageIndex) + } + + return basePath + } + + /** + * Reset the generator's properties for new usage. + */ + reset() { + this.program = undefined + this.checker = undefined + } +} + +export default AbstractGenerator diff --git a/docs-util/packages/docblock-generator/src/classes/generators/oas.ts b/docs-util/packages/docblock-generator/src/classes/generators/oas.ts new file mode 100644 index 0000000000..69a76c3c1b --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/generators/oas.ts @@ -0,0 +1,76 @@ +import { minimatch } from "minimatch" +import AbstractGenerator from "./index.js" +import ts from "typescript" +import OasKindGenerator from "../kinds/oas.js" +import { GeneratorEvent } from "../helpers/generator-event-manager.js" + +/** + * A class used to generate OAS yaml comments. The comments are written + * in different files than the specified files. + */ +class OasGenerator extends AbstractGenerator { + protected oasKindGenerator?: OasKindGenerator + + run() { + this.init() + + const { generateExamples } = this.options + + this.oasKindGenerator = new OasKindGenerator({ + checker: this.checker!, + generatorEventManager: this.generatorEventManager, + additionalOptions: { + generateExamples, + }, + }) + + this.program!.getSourceFiles().map((file) => { + // Ignore .d.ts files + if (file.isDeclarationFile || !this.isFileIncluded(file.fileName)) { + return + } + + console.log(`[OAS] Generating for ${file.fileName}...`) + + const documentChild = (node: ts.Node) => { + if ( + this.oasKindGenerator!.isAllowed(node) && + this.oasKindGenerator!.canDocumentNode(node) + ) { + const oas = this.oasKindGenerator!.getDocBlock(node) + + if (!this.options.dryRun) { + const filename = this.oasKindGenerator!.getAssociatedFileName(node) + ts.sys.writeFile( + filename, + this.formatter.addCommentsToSourceFile(oas, "") + ) + } + } + } + + ts.forEachChild(file, documentChild) + + this.generatorEventManager.emit(GeneratorEvent.FINISHED_GENERATE_EVENT) + console.log(`[OAS] Finished generating OAS for ${file.fileName}.`) + }) + } + + /** + * Checks whether the specified file path is included in the program + * and is an API file. + * + * @param fileName - The file path to check + * @returns Whether the OAS generator can run on this file. + */ + isFileIncluded(fileName: string): boolean { + return ( + super.isFileIncluded(fileName) && + minimatch(this.getBasePath(fileName), "packages/medusa/**/api**/**", { + matchBase: true, + }) + ) + } +} + +export default OasGenerator diff --git a/docs-util/packages/docblock-generator/src/classes/formatter.ts b/docs-util/packages/docblock-generator/src/classes/helpers/formatter.ts similarity index 86% rename from docs-util/packages/docblock-generator/src/classes/formatter.ts rename to docs-util/packages/docblock-generator/src/classes/helpers/formatter.ts index eededa70c2..929a22ebe6 100644 --- a/docs-util/packages/docblock-generator/src/classes/formatter.ts +++ b/docs-util/packages/docblock-generator/src/classes/helpers/formatter.ts @@ -1,10 +1,11 @@ -import getMonorepoRoot from "../utils/get-monorepo-root.js" +import getMonorepoRoot from "../../utils/get-monorepo-root.js" import { ESLint, Linter } from "eslint" import path from "path" -import dirname from "../utils/dirname.js" +import dirname from "../../utils/dirname.js" import { minimatch } from "minimatch" import { existsSync } from "fs" -import getRelativePaths from "../utils/get-relative-paths.js" +import * as prettier from "prettier" +import getRelativePaths from "../../utils/get-relative-paths.js" /** * A class used to apply formatting to files using ESLint and other formatting options. @@ -70,10 +71,10 @@ class Formatter { ) newConfig.parserOptions.project = [ - existsSync(tsConfigSpecPath) - ? tsConfigSpecPath - : existsSync(tsConfigPath) - ? tsConfigPath + existsSync(tsConfigPath) + ? tsConfigPath + : existsSync(tsConfigSpecPath) + ? tsConfigSpecPath : [ ...getRelativePaths( newConfig.parserOptions.project || [], @@ -170,6 +171,10 @@ class Formatter { content: string, fileName: string ): Promise { + const prettifiedContent = await this.formatStrWithPrettier( + content, + fileName + ) const relevantConfig = await this.getESLintOverridesConfigForFile(fileName) const eslint = new ESLint({ @@ -183,8 +188,8 @@ class Formatter { ignore: false, }) - let newContent = content - const result = await eslint.lintText(content, { + let newContent = prettifiedContent + const result = await eslint.lintText(prettifiedContent, { filePath: fileName, }) @@ -195,6 +200,27 @@ class Formatter { return newContent } + /** + * Format a file's content with prettier. + * + * @param content - The content to format. + * @param fileName - The name of the file the content belongs to. + * @returns The formatted content + */ + async formatStrWithPrettier( + content: string, + fileName: string + ): Promise { + // load config of the file + const prettierConfig = (await prettier.resolveConfig(fileName)) || undefined + + if (prettierConfig && !prettierConfig.parser) { + prettierConfig.parser = "babel-ts" + } + + return await prettier.format(content, prettierConfig) + } + /** * Applies all formatting types to a string. * diff --git a/docs-util/packages/docblock-generator/src/classes/helpers/generator-event-manager.ts b/docs-util/packages/docblock-generator/src/classes/helpers/generator-event-manager.ts new file mode 100644 index 0000000000..514c222775 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/helpers/generator-event-manager.ts @@ -0,0 +1,37 @@ +import EventEmitter from "events" + +export enum GeneratorEvent { + FINISHED_GENERATE_EVENT = "finished_generate", +} + +/** + * A class used to emit events during the lifecycle of the generator. + */ +class GeneratorEventManager { + private eventEmitter: EventEmitter + + constructor() { + this.eventEmitter = new EventEmitter() + } + + /** + * Emit an event to listeners. + * + * @param event - The event to emit. + */ + emit(event: GeneratorEvent) { + this.eventEmitter.emit(event) + } + + /** + * Add a listener to an event. + * + * @param event - The event to add a listener for. + * @param handler - The handler of the event. + */ + listen(event: GeneratorEvent, handler: () => void) { + this.eventEmitter.on(event, handler) + } +} + +export default GeneratorEventManager diff --git a/docs-util/packages/docblock-generator/src/classes/git-manager.ts b/docs-util/packages/docblock-generator/src/classes/helpers/git-manager.ts similarity index 100% rename from docs-util/packages/docblock-generator/src/classes/git-manager.ts rename to docs-util/packages/docblock-generator/src/classes/helpers/git-manager.ts diff --git a/docs-util/packages/docblock-generator/src/classes/knowledge-base-factory.ts b/docs-util/packages/docblock-generator/src/classes/helpers/knowledge-base-factory.ts similarity index 87% rename from docs-util/packages/docblock-generator/src/classes/knowledge-base-factory.ts rename to docs-util/packages/docblock-generator/src/classes/helpers/knowledge-base-factory.ts index 78222e4b77..c162afecf2 100644 --- a/docs-util/packages/docblock-generator/src/classes/knowledge-base-factory.ts +++ b/docs-util/packages/docblock-generator/src/classes/helpers/knowledge-base-factory.ts @@ -1,10 +1,12 @@ import ts from "typescript" -import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../constants.js" +import { DOCBLOCK_DOUBLE_LINES, DOCBLOCK_NEW_LINE } from "../../constants.js" import { camelToTitle, camelToWords, normalizeName, -} from "../utils/str-formatting.js" + snakeToWords, +} from "../../utils/str-formatting.js" +import pluralize from "pluralize" type TemplateOptions = { parentName?: string @@ -16,7 +18,10 @@ type KnowledgeBase = { startsWith?: string endsWith?: string exact?: string - template: string | ((str: string, options?: TemplateOptions) => string) + pattern?: RegExp + template: + | string + | ((str: string, options?: TemplateOptions) => string | undefined) kind?: ts.SyntaxKind[] } @@ -213,6 +218,27 @@ class KnowledgeBaseFactory { template: `An object that includes the IDs of related records that were restored, such as the ID of associated {relation name}. ${DOCBLOCK_NEW_LINE}The object's keys are the ID attribute names of the {type name} entity's relations, such as \`{relation ID field name}\`, ${DOCBLOCK_NEW_LINE}and its value is an array of strings, each being the ID of the record associated with the money amount through this relation, ${DOCBLOCK_NEW_LINE}such as the IDs of associated {relation name}.`, }, ] + private oasDescriptionKnowledgeBase: KnowledgeBase[] = [ + { + pattern: /.*/, + template(str, options) { + if (!options?.parentName) { + return + } + + const formattedName = str === "id" ? "ID" : snakeToWords(str) + const formattedParentName = pluralize.singular( + snakeToWords(options.parentName) + ) + + if (formattedName === formattedParentName) { + return `The ${formattedParentName}'s details.` + } + + return `The ${formattedParentName}'s ${formattedName}.` + }, + }, + ] /** * Tries to find in a specified knowledge base a template relevant to the specified name. @@ -235,6 +261,10 @@ class KnowledgeBaseFactory { return str === item.exact } + if (item.pattern) { + return item.pattern.test(str) + } + if (item.kind?.length && (!kind || !item.kind.includes(kind))) { return false } @@ -320,6 +350,23 @@ class KnowledgeBaseFactory { knowledgeBase: this.functionReturnKnowledgeBase, }) } + + /** + * Tries to retrieve the description template of an OAS property from the {@link oasDescriptionKnowledgeBase}. + * + * @returns {string | undefined} The matching knowledgebase template, if found. + */ + tryToGetOasDescription({ + str, + ...options + }: RetrieveOptions): string | undefined { + const normalizedTypeStr = str.replaceAll("[]", "") + return this.tryToFindInKnowledgeBase({ + ...options, + str: normalizedTypeStr, + knowledgeBase: this.oasDescriptionKnowledgeBase, + }) + } } export default KnowledgeBaseFactory diff --git a/docs-util/packages/docblock-generator/src/classes/helpers/oas-schema.ts b/docs-util/packages/docblock-generator/src/classes/helpers/oas-schema.ts new file mode 100644 index 0000000000..ac4b83fa6b --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/helpers/oas-schema.ts @@ -0,0 +1,233 @@ +import { OpenAPIV3 } from "openapi-types" +import { OpenApiSchema } from "../../types/index.js" +import Formatter from "./formatter.js" +import { join } from "path" +import { DOCBLOCK_LINE_ASTRIX } from "../../constants.js" +import ts from "typescript" +import getOasOutputBasePath from "../../utils/get-oas-output-base-path.js" +import { parse } from "yaml" +import formatOas from "../../utils/format-oas.js" +import pluralize from "pluralize" +import { wordsToPascal } from "../../utils/str-formatting.js" + +type ParsedSchema = { + schema: OpenApiSchema + schemaPrefix: string +} + +/** + * Class providing helper methods for OAS Schemas + */ +class OasSchemaHelper { + /** + * This map collects schemas created while generating the OAS, then, once the generation process + * finishes, it checks if it should be added to the base OAS document. + */ + private schemas: Map + protected schemaRefPrefix = "#/components/schemas/" + protected formatter: Formatter + /** + * The path to the directory holding the base YAML files. + */ + protected baseOutputPath: string + + constructor() { + this.schemas = new Map() + this.formatter = new Formatter() + this.baseOutputPath = getOasOutputBasePath() + } + + /** + * Initialize the {@link schemas} property. Helpful when resetting the property. + */ + init() { + this.schemas = new Map() + } + + /** + * Retrieve schema as a reference object and add the schema to the {@link schemas} property. + * + * @param schema - The schema to convert and add to the schemas property. + * @returns The schema as a reference. If the schema doesn't have the x-schemaName property set, + * the schema isn't converted and `undefined` is returned. + */ + schemaToReference( + schema: OpenApiSchema + ): OpenAPIV3.ReferenceObject | undefined { + if (!schema["x-schemaName"]) { + return + } + schema["x-schemaName"] = this.normalizeSchemaName(schema["x-schemaName"]) + + // check if schema has child schemas + // and convert those + if (schema.properties) { + Object.keys(schema.properties).forEach((property) => { + if ( + "$ref" in schema.properties![property] || + !(schema.properties![property] as OpenApiSchema)["x-schemaName"] + ) { + return + } + + schema.properties![property] = + this.schemaToReference( + schema.properties![property] as OpenApiSchema + ) || schema.properties![property] + }) + } + + this.schemas.set(schema["x-schemaName"], schema) + + return { + $ref: this.constructSchemaReference(schema["x-schemaName"]), + } + } + + /** + * Retrieve the expected file name of the schema. + * + * @param name - The schema's name + * @returns The schema's file name + */ + getSchemaFileName(name: string): string { + return join( + this.baseOutputPath, + "schemas", + `${this.normalizeSchemaName(name)}.ts` + ) + } + + /** + * Retrieve the schema by its name. If the schema is in the {@link schemas} map, it'll be retrieved from + * there. Otherwise, the method will try to retrieve it from an outputted schema file, if available. + * + * @param name - The schema's name. + * @returns The parsed schema, if found. + */ + getSchemaByName(name: string): ParsedSchema | undefined { + const schemaName = this.normalizeSchemaName(name) + // check if it already exists in the schemas map + if (this.schemas.has(schemaName)) { + return { + schema: this.schemas.get(schemaName)!, + schemaPrefix: `@schema ${schemaName}`, + } + } + const schemaFile = this.getSchemaFileName(schemaName) + const schemaFileContent = ts.sys.readFile(schemaFile) + + if (!schemaFileContent) { + return + } + + return this.parseSchema(schemaFileContent) + } + + /** + * Parses a schema comment string. + * + * @param content - The schema comment string + * @returns If the schema is valid and parsed successfully, the schema and its prefix are retrieved. + */ + parseSchema(content: string): ParsedSchema | undefined { + const schemaFileContent = content + .replace(`/**\n`, "") + .replaceAll(DOCBLOCK_LINE_ASTRIX, "") + .replaceAll("*/", "") + .trim() + + if (!schemaFileContent.startsWith("@schema")) { + return + } + + const splitContent = schemaFileContent.split("\n") + const schemaPrefix = splitContent[0] + let schema: OpenApiSchema | undefined + + try { + schema = parse(splitContent.slice(1).join("\n")) + } catch (e) { + // couldn't parse the OAS, so consider it + // not existent + } + + return schema + ? { + schema, + schemaPrefix, + } + : undefined + } + + /** + * Retrieve the normalized schema name. A schema's name must be normalized before saved. + * + * @param name - The original name. + * @returns The normalized name. + */ + normalizeSchemaName(name: string): string { + return name.replace("DTO", "").replace(this.schemaRefPrefix, "") + } + + /** + * Construct a reference string to a schema. + * + * @param name - The name of the schema. For cautionary reasons, the name is normalized using the {@link normalizeSchemaName} method. + * @returns The schema reference. + */ + constructSchemaReference(name: string): string { + return `${this.schemaRefPrefix}${this.normalizeSchemaName(name)}` + } + + /** + * Writes schemas in the {@link schemas} property to the file path retrieved using the {@link getSchemaFileName} method. + */ + writeNewSchemas() { + this.schemas.forEach((schema) => { + if (!schema["x-schemaName"]) { + return + } + const normalizedName = this.normalizeSchemaName(schema["x-schemaName"]) + const schemaFileName = this.getSchemaFileName(normalizedName) + + ts.sys.writeFile( + schemaFileName, + this.formatter.addCommentsToSourceFile( + formatOas(schema, `@schema ${normalizedName}`), + "" + ) + ) + }) + } + + /** + * Checks whether an object is a reference object. + * + * @param schema - The schema object to check. + * @returns Whether the object is a reference object. + */ + isRefObject( + schema: + | OpenAPIV3.ReferenceObject + | OpenApiSchema + | OpenAPIV3.RequestBodyObject + | OpenAPIV3.ResponseObject + | undefined + ): schema is OpenAPIV3.ReferenceObject { + return schema !== undefined && "$ref" in schema + } + + /** + * Converts a tag name to a schema name. Can be used to try and retrieve the schema + * associated with a tag. + * + * @param tagName - The name of the tag. + * @returns The possible name of the associated schema. + */ + tagNameToSchemaName(tagName: string): string { + return wordsToPascal(pluralize.singular(tagName)) + } +} + +export default OasSchemaHelper diff --git a/docs-util/packages/docblock-generator/src/classes/kinds/default.ts b/docs-util/packages/docblock-generator/src/classes/kinds/default.ts index 7cd16cbf40..ca93d83003 100644 --- a/docs-util/packages/docblock-generator/src/classes/kinds/default.ts +++ b/docs-util/packages/docblock-generator/src/classes/kinds/default.ts @@ -8,7 +8,7 @@ import { import getSymbol from "../../utils/get-symbol.js" import KnowledgeBaseFactory, { RetrieveOptions, -} from "../knowledge-base-factory.js" +} from "../helpers/knowledge-base-factory.js" import { getCustomNamespaceTag, shouldHaveCustomNamespace, @@ -18,10 +18,14 @@ import { capitalize, normalizeName, } from "../../utils/str-formatting.js" +import GeneratorEventManager from "../helpers/generator-event-manager.js" +import { CommonCliOptions } from "../../types/index.js" export type GeneratorOptions = { checker: ts.TypeChecker kinds?: ts.SyntaxKind[] + generatorEventManager: GeneratorEventManager + additionalOptions: Pick } export type GetDocBlockOptions = { @@ -54,11 +58,20 @@ class DefaultKindGenerator { protected checker: ts.TypeChecker protected defaultSummary = "{summary}" protected knowledgeBaseFactory: KnowledgeBaseFactory + protected generatorEventManager: GeneratorEventManager + protected options: Pick - constructor({ checker, kinds }: GeneratorOptions) { + constructor({ + checker, + kinds, + generatorEventManager, + additionalOptions, + }: GeneratorOptions) { this.allowedKinds = kinds || DefaultKindGenerator.DEFAULT_ALLOWED_NODE_KINDS this.checker = checker this.knowledgeBaseFactory = new KnowledgeBaseFactory() + this.generatorEventManager = generatorEventManager + this.options = additionalOptions } /** @@ -136,7 +149,11 @@ class DefaultKindGenerator { */ nodeType?: ts.Type }): string { - const knowledgeBaseOptions = this.getKnowledgeOptions(node) + const syntheticComments = ts.getSyntheticLeadingComments(node) + if (syntheticComments?.length) { + return syntheticComments.map((comment) => comment.text).join(" ") + } + const knowledgeBaseOptions = this.getKnowledgeBaseOptions(node) if (!nodeType) { nodeType = "type" in node && node.type && ts.isTypeNode(node.type as ts.Node) @@ -170,7 +187,7 @@ class DefaultKindGenerator { * @param {ts.Type} nodeType - The type of a node. * @returns {string} The summary comment. */ - private getTypeDocBlock( + protected getTypeDocBlock( nodeType: ts.Type, knowledgeBaseOptions?: Partial ): string { @@ -226,7 +243,7 @@ class DefaultKindGenerator { * @param {ts.Symbol} symbol - The symbol to retrieve its docblock. * @returns {string} The symbol's docblock. */ - private getSymbolDocBlock( + protected getSymbolDocBlock( symbol: ts.Symbol, knowledgeBaseOptions?: Partial ): string { @@ -395,26 +412,9 @@ class DefaultKindGenerator { } // check for default value - if ( - "initializer" in node && - node.initializer && - ts.isExpression(node.initializer as ts.Node) - ) { - const initializer = node.initializer as ts.Expression - - // retrieve default value only if the value is numeric, string, or boolean - const defaultValue = - ts.isNumericLiteral(initializer) || ts.isStringLiteral(initializer) - ? initializer.getText() - : initializer.kind === ts.SyntaxKind.FalseKeyword - ? "false" - : initializer.kind === ts.SyntaxKind.TrueKeyword - ? "true" - : "" - - if (defaultValue.length) { - tags.add(`@defaultValue ${defaultValue}`) - } + const defaultValue = this.getDefaultValue(node) + if (defaultValue?.length) { + tags.add(`@defaultValue ${defaultValue}`) } let str = "" @@ -481,7 +481,13 @@ class DefaultKindGenerator { ) } - getKnowledgeOptions(node: ts.Node): Partial { + /** + * Get knowledge base options for a specified node. + * + * @param node - The node to retrieve its knowledge base options. + * @returns The knowledge base options. + */ + getKnowledgeBaseOptions(node: ts.Node): Partial { const rawParentName = "name" in node.parent && node.parent.name && @@ -498,6 +504,88 @@ class DefaultKindGenerator { }, } } + + /** + * Get the default value of a node. + * + * @param node - The node to get its default value. + * @returns The default value, if any. + */ + getDefaultValue(node: ts.Node): string | undefined { + if ( + "initializer" in node && + node.initializer && + ts.isExpression(node.initializer as ts.Node) + ) { + const initializer = node.initializer as ts.Expression + + // retrieve default value only if the value is numeric, string, or boolean + const defaultValue = + ts.isNumericLiteral(initializer) || ts.isStringLiteral(initializer) + ? initializer.getText() + : initializer.kind === ts.SyntaxKind.FalseKeyword + ? "false" + : initializer.kind === ts.SyntaxKind.TrueKeyword + ? "true" + : "" + + if (defaultValue.length) { + return defaultValue + } + } + } + + /** + * Checks whether a node can be documented. + * + * @param {ts.Node} node - The node to check for. + * @returns {boolean} Whether the node can be documented. + */ + canDocumentNode(node: ts.Node): boolean { + // check if node already has docblock + return !this.nodeHasComments(node) + } + + /** + * Get the comments range of a node. + * @param node - The node to get its comment range. + * @returns The comment range of the node if available. + */ + getNodeCommentsRange(node: ts.Node): ts.CommentRange[] | undefined { + return ts.getLeadingCommentRanges( + node.getSourceFile().getFullText(), + node.getFullStart() + ) + } + + /** + * Get a node's comment from its range. + * + * @param node - The node to get its comment range. + * @returns The comment if available. + */ + getNodeCommentsFromRange(node: ts.Node): string | undefined { + const commentRange = this.getNodeCommentsRange(node) + + if (!commentRange?.length) { + return + } + + return node + .getSourceFile() + .getFullText() + .slice(commentRange[0].pos, commentRange[0].end) + } + + /** + * Check whether a node has comments. + * + * @param node - The node to check. + * @returns Whether the node has comments. + */ + nodeHasComments(node: ts.Node): boolean { + return this.getNodeCommentsFromRange(node) !== undefined + } } export default DefaultKindGenerator diff --git a/docs-util/packages/docblock-generator/src/classes/kinds/function.ts b/docs-util/packages/docblock-generator/src/classes/kinds/function.ts index fc20a60289..2ae6aaa030 100644 --- a/docs-util/packages/docblock-generator/src/classes/kinds/function.ts +++ b/docs-util/packages/docblock-generator/src/classes/kinds/function.ts @@ -14,7 +14,7 @@ export type FunctionNode = | ts.FunctionDeclaration | ts.ArrowFunction -type VariableNode = ts.VariableDeclaration | ts.VariableStatement +export type VariableNode = ts.VariableDeclaration | ts.VariableStatement export type FunctionOrVariableNode = FunctionNode | ts.VariableStatement diff --git a/docs-util/packages/docblock-generator/src/classes/kinds/medusa-react-hooks.ts b/docs-util/packages/docblock-generator/src/classes/kinds/medusa-react-hooks.ts index b2b4beaa05..2d9332fa73 100644 --- a/docs-util/packages/docblock-generator/src/classes/kinds/medusa-react-hooks.ts +++ b/docs-util/packages/docblock-generator/src/classes/kinds/medusa-react-hooks.ts @@ -9,7 +9,6 @@ import { DOCBLOCK_START, DOCBLOCK_DOUBLE_LINES, } from "../../constants.js" -import nodeHasComments from "../../utils/node-has-comments.js" import { CUSTOM_NAMESPACE_TAG, getCustomNamespaceTag, @@ -152,7 +151,7 @@ class MedusaReactHooksKindGenerator extends FunctionKindGenerator { return ( !parameterTypeStr?.startsWith("UseQueryOptionsWrapper") && !parameterTypeStr?.startsWith("UseMutationOptions") && - !nodeHasComments(parameter) + !this.nodeHasComments(parameter) ) }) } diff --git a/docs-util/packages/docblock-generator/src/classes/kinds/oas.ts b/docs-util/packages/docblock-generator/src/classes/kinds/oas.ts new file mode 100644 index 0000000000..8a810afeb5 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/classes/kinds/oas.ts @@ -0,0 +1,1870 @@ +import ts, { SyntaxKind } from "typescript" +import FunctionKindGenerator, { + FunctionNode, + FunctionOrVariableNode, + VariableNode, +} from "./function.js" +import { GeneratorOptions, GetDocBlockOptions } from "./default.js" +import { basename, join } from "path" +import { + capitalize, + kebabToTitle, + wordsToKebab, +} from "../../utils/str-formatting.js" +import { + OpenApiDocument, + OpenApiOperation, + OpenApiSchema, +} from "../../types/index.js" +import { OpenAPIV3 } from "openapi-types" +import { parse, stringify } from "yaml" +import { GeneratorEvent } from "../helpers/generator-event-manager.js" +import { readFileSync, writeFileSync } from "fs" +import OasExamplesGenerator from "../examples/oas.js" +import pluralize from "pluralize" +import getOasOutputBasePath from "../../utils/get-oas-output-base-path.js" +import parseOas, { ExistingOas } from "../../utils/parse-oas.js" +import OasSchemaHelper from "../helpers/oas-schema.js" +import formatOas from "../../utils/format-oas.js" +import { DEFAULT_OAS_RESPONSES } from "../../constants.js" + +export const API_ROUTE_PARAM_REGEX = /\[(.+)\]/g +const RES_STATUS_REGEX = /^res[\s\S]*\.status\((\d+)\)/ + +type SchemaDescriptionOptions = { + symbol?: ts.Symbol + node?: ts.Node + nodeType?: ts.Type + typeStr: string + parentName?: string +} + +export type OasArea = "admin" | "store" + +type ParameterType = "query" | "path" + +/** + * OAS generator for API routes. It extends the {@link FunctionKindGenerator} + * since API routes are functions. + */ +class OasKindGenerator extends FunctionKindGenerator { + protected allowedKinds: SyntaxKind[] = [ts.SyntaxKind.FunctionDeclaration] + private MAX_LEVEL = 4 + // we can't use `{summary}` because it causes an MDX error + // when we finally render the summary. We can alternatively + // use `\{summary\}` but it wouldn't look pretty in the OAS, + // so doing this for now. + protected defaultSummary = "SUMMARY" + + /** + * This map collects tags of all the generated OAS, then, once the generation process finishes, + * it checks if it should be added to the base OAS document of the associated area. + */ + private tags: Map> + /** + * The path to the directory holding the base YAML files. + */ + protected baseOutputPath: string + protected oasExamplesGenerator: OasExamplesGenerator + protected oasSchemaHelper: OasSchemaHelper + + constructor(options: GeneratorOptions) { + super(options) + + this.oasExamplesGenerator = new OasExamplesGenerator() + this.baseOutputPath = getOasOutputBasePath() + + this.tags = new Map() + this.oasSchemaHelper = new OasSchemaHelper() + this.init() + + this.generatorEventManager.listen( + GeneratorEvent.FINISHED_GENERATE_EVENT, + this.afterGenerate.bind(this) + ) + } + + /** + * Check whether the generator can be used for the specified node. The node must be a function that has + * two parameters of types `MedusaRequest` and `MedusaResponse` respectively. + * + * @param node - The node to check. + * @returns Whether the generator can be used for the specified node. + */ + isAllowed(node: ts.Node): node is FunctionOrVariableNode { + const isFunction = + this.allowedKinds.includes(node.kind) || + (ts.isVariableStatement(node) && this.isFunctionVariable(node)) + + if (!isFunction) { + return false + } + + const functionNode = ts.isFunctionDeclaration(node) + ? node + : this.extractFunctionNode(node as VariableNode) + + if (!functionNode) { + return false + } + + // function must have 2 parameters, first parameter of type `MedusaRequest` + // and the second of type `MedusaResponse` + return ( + (functionNode.parameters.length === 2 && + functionNode.parameters[0].type + ?.getText() + .startsWith("MedusaRequest") && + functionNode.parameters[1].type + ?.getText() + .startsWith("MedusaResponse")) || + false + ) + } + + /** + * Check whether the node can be documented. + * + * @param node - The node to check. + * @returns Whether the node can be documented. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + canDocumentNode(node: ts.Node): boolean { + // unlike other generators, this one + // can update existing OAS, so we just return + // true + return true + } + + /** + * Try to retrieve the OAS previously generated for the function. If the OAS is retrieved, the + * OAS is updated rather than created. + * + * @param node - The node to retrieve its existing OAS. + * @returns The node's existing OAS, if available. + */ + getExistingOas(node: FunctionOrVariableNode): ExistingOas | undefined { + // read the file holding the OAS, if it's available. + const fileContent = ts.sys.readFile(this.getAssociatedFileName(node)) + + if (!fileContent) { + // the file doesn't exist, meaning there's no existing OAS. + return + } + + return parseOas(fileContent) + } + + /** + * Retrieves the docblock of the node. If the node has existing OAS, the OAS is updated and returned. Otherwise, + * the OAS is generated. + * + * @param node - The node to get its OAS. + * @param options - The options to get the OAS. + * @returns The OAS as a string that can be used as a comment in a TypeScript file. + */ + getDocBlock( + node: ts.Node | FunctionOrVariableNode, + options?: GetDocBlockOptions + ): string { + if (!this.isAllowed(node)) { + return super.getDocBlock(node, options) + } + + const actualNode = ts.isVariableStatement(node) + ? this.extractFunctionNode(node) + : node + + if (!actualNode) { + return super.getDocBlock(node, options) + } + const methodName = this.getHTTPMethodName(node) + + const existingOas = this.getExistingOas(node) + + if (existingOas?.oas) { + return this.updateExistingOas({ + node: actualNode, + methodName, + oasOptions: existingOas, + }) + } + + return this.getNewOasDocBlock({ + node: actualNode, + methodName, + }) + } + + /** + * Generate OAS of a node. + * + * @param param0 - The node's details. + * @returns The OAS comment. + */ + getNewOasDocBlock({ + node, + methodName, + }: { + /** + * The node to generate its OAS. + */ + node: FunctionNode + /** + * The lowercase name of the method. For example, `get`. + */ + methodName: string + }): string { + // collect necessary variables + const { oasPath, normalized: normalizedOasPath } = this.getOasPath(node) + const splitOasPath = oasPath.split("/") + const oasPrefix = this.getOasPrefix(methodName, normalizedOasPath) + const { isAdminAuthenticated, isStoreAuthenticated, isAuthenticated } = + this.getAuthenticationDetails(node, oasPath) + const tagName = this.getTagName(splitOasPath) + const { summary, description } = this.getSummaryAndDescription({ + oasPath, + httpMethod: methodName, + tag: tagName || "", + }) + + // construct oas + const oas: OpenApiOperation = { + operationId: this.getOperationId({ + methodName, + splitOasPath, + }), + summary, + description, + "x-authenticated": isAuthenticated, + parameters: this.getPathParameters({ oasPath, tagName }), + security: [], + } + + // retreive query and request parameters + const { queryParameters, requestSchema } = this.getRequestParameters({ + node, + tagName, + }) + + oas.parameters?.push(...queryParameters) + if (requestSchema && Object.keys(requestSchema).length > 0) { + oas.requestBody = { + content: { + "application/json": { + schema: + this.oasSchemaHelper.schemaToReference(requestSchema) || + requestSchema, + }, + }, + } + } + + // retrieve response schema + const responseSchema = this.getResponseSchema({ + node, + tagName, + }) + + // retrieve code examples + // only generate cURL examples, and for the rest + // check if the --generate-examples option is enabled + oas["x-codeSamples"] = [ + { + ...OasExamplesGenerator.CURL_CODESAMPLE_DATA, + source: this.oasExamplesGenerator.generateCurlExample({ + method: methodName, + path: normalizedOasPath, + isAdminAuthenticated, + isStoreAuthenticated, + requestSchema, + }), + }, + ] + + if (this.options.generateExamples) { + oas["x-codeSamples"].push( + { + ...OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA, + source: this.oasExamplesGenerator.generateJSClientExample({ + oasPath, + httpMethod: methodName, + area: splitOasPath[0] as OasArea, + tag: tagName || "", + isAdminAuthenticated, + isStoreAuthenticated, + parameters: (oas.parameters as OpenAPIV3.ParameterObject[])?.filter( + (parameter) => parameter.in === "path" + ), + requestBody: requestSchema, + responseBody: responseSchema, + }), + }, + { + ...OasExamplesGenerator.MEDUSAREACT_CODESAMPLE_DATA, + source: "EXAMPLE", // TODO figure out if we can generate examples for medusa react + } + ) + } + + // add security details if applicable + oas.security = this.getSecurity({ isAdminAuthenticated, isAuthenticated }) + + if (tagName) { + oas.tags = [tagName] + } + + // detect returned response status + const responseStatus = this.getResponseStatus(node) + + // add responses + oas.responses = { + [responseStatus]: { + description: "OK", + }, + } + + if (responseSchema && Object.keys(responseSchema).length > 0) { + ;(oas.responses[responseStatus] as OpenAPIV3.ResponseObject).content = { + "application/json": { + schema: + this.oasSchemaHelper.schemaToReference(responseSchema) || + responseSchema, + }, + } + } + + oas.responses = { + ...(oas.responses || {}), + ...DEFAULT_OAS_RESPONSES, + } + + // push new tag to the tags property + if (tagName) { + const areaTags = this.tags.get(splitOasPath[0] as OasArea) + areaTags?.add(tagName) + } + + return formatOas(oas, oasPrefix) + } + + /** + * Update an existing OAS operation. + * + * @param param0 - The OAS's details. + * @returns The updated OAS + */ + updateExistingOas({ + node, + methodName, + oasOptions: { oas, oasPrefix }, + }: { + /** + * The node that the OAS is associated with. + */ + node: FunctionNode + /** + * The lower case method name of the operation. + */ + methodName: string + /** + * The existing OAS's details. + */ + oasOptions: ExistingOas + }): string { + // collect necessary variables + const { oasPath, normalized: normalizedOasPath } = this.getOasPath(node) + const splitOasPath = oasPath.split("/") + const tagName = this.getTagName(splitOasPath) + + // update tag name + oas.tags = tagName ? [tagName] : [] + + // check if the prefix line should be updated. + const updatedOasPrefix = this.getOasPrefix(methodName, normalizedOasPath) + if (updatedOasPrefix !== oasPrefix) { + oasPrefix = updatedOasPrefix + } + + // check if operation ID should be updated + const updatedOperationId = this.getOperationId({ + methodName, + splitOasPath, + }) + + if (updatedOperationId !== oas.operationId) { + oas.operationId = updatedOperationId + } + + // update summary and description either if they're empty or default summary + const shouldUpdateSummary = + !oas.summary || oas.summary === this.defaultSummary + const shouldUpdateDescription = + !oas.description || oas.description === this.defaultSummary + if (shouldUpdateSummary || shouldUpdateDescription) { + const { summary, description } = this.getSummaryAndDescription({ + oasPath, + httpMethod: methodName, + tag: tagName || "", + }) + + if (shouldUpdateSummary) { + oas.summary = summary + } + + if (shouldUpdateDescription) { + oas.description = description + } + } + + // check if authentication details (including security) should be updated + const { isAdminAuthenticated, isStoreAuthenticated, isAuthenticated } = + this.getAuthenticationDetails(node, oasPath) + + oas["x-authenticated"] = isAuthenticated + oas.security = this.getSecurity({ isAdminAuthenticated, isAuthenticated }) + + // update path parameters + const newPathParameters = this.getPathParameters({ oasPath, tagName }) + oas.parameters = this.updateParameters({ + oldParameters: oas.parameters as OpenAPIV3.ParameterObject[], + newParameters: newPathParameters, + type: "path", + }) + + // retrieve updated query and request schemas + const { queryParameters, requestSchema } = this.getRequestParameters({ + node, + tagName, + }) + + // update query parameters + oas.parameters = this.updateParameters({ + oldParameters: oas.parameters as OpenAPIV3.ParameterObject[], + newParameters: queryParameters, + type: "query", + }) + + // update request schema + const existingRequestBodySchema = ( + oas.requestBody as OpenAPIV3.RequestBodyObject + )?.content?.["application/json"].schema as OpenApiSchema + const updatedRequestSchema = this.updateSchema({ + oldSchema: existingRequestBodySchema, + newSchema: requestSchema, + }) + + if (!updatedRequestSchema && existingRequestBodySchema) { + // if there's no request schema, remove it from the OAS + delete oas.requestBody + } else { + // update the schema + oas.requestBody = { + content: { + "application/json": { + schema: updatedRequestSchema + ? this.oasSchemaHelper.schemaToReference(updatedRequestSchema) || + updatedRequestSchema + : updatedRequestSchema, + }, + }, + } + } + + // update response schema and status + const newStatus = this.getResponseStatus(node) + const newResponseSchema = this.getResponseSchema({ + node, + tagName, + }) + let updatedResponseSchema: OpenApiSchema | undefined + + if (!oas.responses && newResponseSchema) { + // add response schema + oas.responses = { + [newStatus]: { + description: "OK", + content: { + "application/json": { + schema: + this.oasSchemaHelper.schemaToReference(newResponseSchema) || + newResponseSchema, + }, + }, + }, + ...DEFAULT_OAS_RESPONSES, + } + updatedResponseSchema = newResponseSchema + } else if (oas.responses && !newResponseSchema) { + // remove response schema by only keeping the default responses + oas.responses = DEFAULT_OAS_RESPONSES + } else { + // check if response status should be changed + const oldResponseStatus = Object.keys(oas.responses!).find( + (status) => !Object.keys(DEFAULT_OAS_RESPONSES).includes(status) + ) + const oldResponseSchema = oldResponseStatus + ? ((oas.responses![oldResponseStatus] as OpenAPIV3.ResponseObject) + .content?.["application/json"].schema as OpenApiSchema) + : undefined + + updatedResponseSchema = this.updateSchema({ + oldSchema: oldResponseSchema, + newSchema: newResponseSchema, + }) + + if (oldResponseStatus && oldResponseSchema !== newStatus) { + // delete the old response schema if its status is different + delete oas.responses![oldResponseStatus] + } + + // update the response schema + oas.responses![newStatus] = { + description: "OK", + content: { + "application/json": { + schema: updatedResponseSchema + ? this.oasSchemaHelper.schemaToReference(updatedResponseSchema) || + updatedResponseSchema + : updatedResponseSchema, + }, + }, + } + } + + // update examples if the --generate-examples option is enabled + if (this.options.generateExamples) { + const oldJsExampleIndex = oas["x-codeSamples"] + ? oas["x-codeSamples"].findIndex( + (example) => + example.label == + OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA.label + ) + : -1 + + if (oldJsExampleIndex === -1) { + // only generate a new example if it doesn't have an example + const newJsExample = this.oasExamplesGenerator.generateJSClientExample({ + oasPath, + httpMethod: methodName, + area: splitOasPath[0] as OasArea, + tag: tagName || "", + isAdminAuthenticated, + isStoreAuthenticated, + parameters: (oas.parameters as OpenAPIV3.ParameterObject[])?.filter( + (parameter) => parameter.in === "path" + ), + requestBody: updatedRequestSchema, + responseBody: updatedResponseSchema, + }) + + oas["x-codeSamples"] = [ + ...(oas["x-codeSamples"] || []), + { + ...OasExamplesGenerator.JSCLIENT_CODESAMPLE_DATA, + source: newJsExample, + }, + ] + } + + // TODO add for Medusa React once we figure out how to generate it + } + + // check if cURL example should be updated. + const oldCurlExampleIndex = oas["x-codeSamples"] + ? oas["x-codeSamples"].findIndex( + (example) => + example.label === OasExamplesGenerator.CURL_CODESAMPLE_DATA.label + ) + : -1 + + if (oldCurlExampleIndex === -1) { + // only generate example if it doesn't already exist + const newCurlExample = this.oasExamplesGenerator.generateCurlExample({ + method: methodName, + path: normalizedOasPath, + isAdminAuthenticated, + isStoreAuthenticated, + requestSchema, + }) + oas["x-codeSamples"] = [ + ...(oas["x-codeSamples"] || []), + { + ...OasExamplesGenerator.CURL_CODESAMPLE_DATA, + source: newCurlExample, + }, + ] + } + + // push new tags to the tags property + if (tagName) { + const areaTags = this.tags.get(splitOasPath[0] as OasArea) + areaTags?.add(tagName) + } + + return formatOas(oas, oasPrefix) + } + + /** + * Get the API route's path details. + * + * @param node - The node to retrieve its path details. + * @returns The path details. + */ + getOasPath(node: FunctionOrVariableNode): { + /** + * The path, generally left as-is, which helps detecting path parameters. + */ + oasPath: string + /** + * The normalized path which adds a backslash at the beginning of the + * oasPath and replaces path parameters of pattern `[paramName]` with + * `{paramName}`. This can be used in the prefix line of the OAS. + */ + normalized: string + } { + const filePath = node.getSourceFile().fileName + const oasPath = ( + filePath.includes("/api-v2/") + ? filePath.substring(filePath.indexOf("/api-v2/")) + : filePath.substring(filePath.indexOf("/api/")) + ) + .replace(/^\/api(-v2)?\//, "") + .replace(`/${basename(filePath)}`, "") + const normalizedOasPath = `/${oasPath.replaceAll( + API_ROUTE_PARAM_REGEX, + `{$1}` + )}` + + return { + oasPath, + normalized: normalizedOasPath, + } + } + + /** + * Get the function's name, which is used to retrieve the HTTP method. + * + * @param node - The node to retrieve its function name. + * @returns the name of the function. + */ + getFunctionName(node: FunctionOrVariableNode): string { + if (ts.isFunctionDeclaration(node)) { + return node.name?.getText() || "" + } + + return ( + node as ts.VariableStatement + ).declarationList.declarations[0].name.getText() + } + + /** + * Retrieve the HTTP method of a node. + * + * @param node - The node to retrieve its HTTP method. + * @returns The lowercase HTTP method name. + */ + getHTTPMethodName(node: FunctionOrVariableNode): string { + return this.getFunctionName(node).toLowerCase() + } + + /** + * Retrieve the OAS prefix line that's added before the YAML schema. + * + * @param methodName - The HTTP method name. + * @param oasPath - The API route's path + * @returns The OAS prefix line. + */ + getOasPrefix(methodName: string, oasPath: string): string { + return `@oas [${methodName}] ${oasPath}` + } + + /** + * Retrieve the tag name from the split OAS path. + * + * @param splitOasPath - The split OAS path. + * @returns The tag name if available. + */ + getTagName(splitOasPath: string[]): string | undefined { + return splitOasPath.length >= 2 ? kebabToTitle(splitOasPath[1]) : undefined + } + + /** + * Retrieve the authentication details of a node. + * + * @param node - The node to retrieve its authentication details. + * @param oasPath - The OAS path of the node. + * @returns The authentication details. + */ + getAuthenticationDetails( + node: FunctionNode, + oasPath: string + ): { + /** + * Whether the OAS operation requires admin authentication. + */ + isAdminAuthenticated: boolean + /** + * Whether the OAS operation requires customer authentication. + */ + isStoreAuthenticated: boolean + /** + * Whether the OAS operation requires authentication in genral. + */ + isAuthenticated: boolean + } { + const isAuthenticationDisabled = node + .getSourceFile() + .statements.some((statement) => + statement.getText().includes("AUTHENTICATE = false") + ) + const isAdminAuthenticated = + !isAuthenticationDisabled && + oasPath.startsWith("admin") && + !oasPath.startsWith("admin/auth") + const isStoreAuthenticated = + !isAuthenticationDisabled && oasPath.startsWith("store/me") + const isAuthenticated = isAdminAuthenticated || isStoreAuthenticated + + return { + isAdminAuthenticated, + isStoreAuthenticated, + isAuthenticated, + } + } + + /** + * Retrieve the OAS operation's ID. + * + * @param param0 - The OAS operation's details. + * @returns The operation's ID. + */ + getOperationId({ + methodName, + splitOasPath, + }: { + /** + * The HTTP method's name. + */ + methodName: string + /** + * The split OAS path. + */ + splitOasPath: string[] + }): string { + let str = capitalize(methodName) + splitOasPath.slice(1).forEach((item) => { + if (API_ROUTE_PARAM_REGEX.test(item)) { + item = item.replace(API_ROUTE_PARAM_REGEX, "$1") + } + + str += item + .split("-") + .map((subitem) => capitalize(subitem)) + .join("") + }) + + return str + } + + /** + * Retrieve the summary and description of the OAS. + * + * @param param0 - The OAS operation's details. + * @returns The summary and description. + */ + getSummaryAndDescription({ + oasPath, + httpMethod, + tag, + }: { + /** + * The OAS path. + */ + oasPath: string + /** + * The HTTP method name. + */ + httpMethod: string + /** + * The OAS tag name. + */ + tag: string + }): { + /** + * The OAS's summary + */ + summary: string + /** + * The OAS's description. + */ + description: string + } { + // reset regex manually + API_ROUTE_PARAM_REGEX.lastIndex = 0 + const result = { + summary: this.defaultSummary, + description: this.defaultSummary, + } + // retrieve different variations of the tag to include in the summary/description + const lowerTag = tag.toLowerCase() + const singularLowerTag = pluralize.singular(lowerTag) + const singularTag = pluralize.singular(tag) + + // check if the OAS operation is performed on a single entity or + // general entities. If the operation has a path parameter, then it's + // considered for a single entity. + const isForSingleEntity = API_ROUTE_PARAM_REGEX.test(oasPath) + + if (isForSingleEntity) { + // Check whether the OAS operation is applied on a different entity. + // If the OAS path ends with /batch or a different entity + // name than the tag name, then it's performed on an entity other than the + // main entity (the one indicated by the tag), so the summary/description vary + // slightly. + const splitOasPath = oasPath + .replaceAll(API_ROUTE_PARAM_REGEX, "") + .replace(/\/(batch)*$/, "") + .split("/") + const isBulk = oasPath.endsWith("/batch") + const isOperationOnDifferentEntity = + wordsToKebab(tag) !== splitOasPath[splitOasPath.length - 1] + + if (isBulk || isOperationOnDifferentEntity) { + // if the operation is a bulk operation and it ends with a path parameter (after removing the `/batch` part) + // then the tag name is the targeted entity. Else, it's the last part of the OAS path (after removing the `/batch` part). + const endingEntityName = + isBulk && + API_ROUTE_PARAM_REGEX.test(splitOasPath[splitOasPath.length - 1]) + ? tag + : kebabToTitle(splitOasPath[splitOasPath.length - 1]) + // retrieve different formatted versions of the entity name for the summary/description + const pluralEndingEntityName = pluralize.plural(endingEntityName) + const lowerEndingEntityName = pluralEndingEntityName.toLowerCase() + const singularLowerEndingEntityName = + pluralize.singular(endingEntityName) + + // set the summary/description based on the HTTP method + if (httpMethod === "get") { + result.summary = `List ${pluralEndingEntityName}` + result.description = `Retrieve a list of ${lowerEndingEntityName} in a ${singularLowerTag}. The ${lowerEndingEntityName} can be filtered by fields like FILTER FIELDS. The ${lowerEndingEntityName} can also be paginated.` + } else if (httpMethod === "post") { + result.summary = `Add ${pluralEndingEntityName} to ${singularTag}` + result.description = `Add a list of ${lowerEndingEntityName} to a ${singularLowerTag}.` + } else { + result.summary = `Remove ${pluralEndingEntityName} from ${singularTag}` + result.description = `Remove a list of ${lowerEndingEntityName} from a ${singularLowerTag}. This doesn't delete the ${singularLowerEndingEntityName}, only the association between the ${singularLowerEndingEntityName} and the ${singularLowerTag}.` + } + } else { + // the OAS operation is applied on a single entity that is the main entity (denoted by the tag). + // retrieve the summary/description based on the HTTP method. + if (httpMethod === "get") { + result.summary = `Get a ${singularTag}` + result.description = `Retrieve a ${singularLowerTag} by its ID. You can expand the ${singularLowerTag}'s relations or select the fields that should be returned.` + } else if (httpMethod === "post") { + result.summary = `Update a ${singularTag}` + result.description = `Update a ${singularLowerTag}'s details.` + } else { + result.summary = `Delete a ${singularTag}` + result.description = `Delete a ${singularLowerTag}.` + } + } + } else { + // the OAS operation is applied on all entities of the tag in general. + // retrieve the summary/description based on the HTTP method. + if (httpMethod === "get") { + result.summary = `List ${tag}` + result.description = `Retrieve a list of ${lowerTag}. The ${lowerTag} can be filtered by fields such as \`id\`. The ${lowerTag} can also be sorted or paginated.` + } else if (httpMethod === "post") { + result.summary = `Create ${singularTag}` + result.description = `Create a ${singularLowerTag}.` + } else { + result.summary = `Delete ${tag}` + result.description = `Delete ${tag}` + } + } + + return result + } + + /** + * Retrieve the security details of an OAS operation. + * + * @param param0 - The authentication details. + * @returns The security details. + */ + getSecurity({ + isAdminAuthenticated, + isAuthenticated, + }: { + /** + * Whether the operation requires admin authentication. + */ + isAdminAuthenticated: boolean + /** + * Whether the operation requires general authentication. + */ + isAuthenticated: boolean + }): OpenAPIV3.SecurityRequirementObject[] | undefined { + const security: OpenAPIV3.SecurityRequirementObject[] = [] + if (isAdminAuthenticated) { + security.push({ + api_token: [], + }) + } + if (isAuthenticated) { + security.push( + { + cookie_auth: [], + }, + { + jwt_token: [], + } + ) + } + + return security.length ? security : undefined + } + + /** + * Format a schema as a parameter object. Can be used for path or query parameters. + * + * @param param0 - The operation's details. + * @returns The parameter object. + */ + getParameterObject({ + type, + name, + description, + required, + schema, + }: { + /** + * The parameter type. + */ + type: "path" | "query" + /** + * The name of the parameter. + */ + name: string + /** + * Whether the parameter is required. + */ + required: boolean + /** + * The parameter's description. + */ + description?: string + /** + * The parameter's schema. + */ + schema: OpenApiSchema + }): OpenAPIV3.ParameterObject { + return { + name: name, + in: type, + description: description, + required: required, + schema: schema, + } + } + + /** + * Retrieve the path parameters. + * + * @param param0 - The OAS operation's details. + * @returns The list of path parameters. + */ + getPathParameters({ + oasPath, + tagName, + }: { + /** + * The OAS path. + */ + oasPath: string + /** + * The tag name. + */ + tagName?: string + }): OpenAPIV3.ParameterObject[] { + const pathParameters = API_ROUTE_PARAM_REGEX.exec(oasPath)?.slice(1) + const parameters: OpenAPIV3.ParameterObject[] = [] + + if (pathParameters?.length) { + pathParameters.forEach((parameter) => + parameters.push( + this.getParameterObject({ + type: "path", + name: parameter, + description: this.getSchemaDescription({ + typeStr: parameter, + parentName: tagName, + }), + required: true, + schema: { + type: "string", + }, + }) + ) + ) + } + + return parameters + } + + /** + * Retrieve the request query parameters and body schema. + * + * @param param0 - The operation's details. + * @returns The request query parameters and body schema. + */ + getRequestParameters({ + node, + tagName, + }: { + /** + * The node to retrieve its request parameters. + */ + node: FunctionNode + /** + * The tag's name. + */ + tagName?: string + }): { + /** + * The query parameters. + */ + queryParameters: OpenAPIV3.ParameterObject[] + /** + * The request schema. + */ + requestSchema?: OpenApiSchema + } { + const parameters: OpenAPIV3.ParameterObject[] = [] + let requestSchema: OpenApiSchema | undefined + + if ( + node.parameters[0].type && + ts.isTypeReferenceNode(node.parameters[0].type) + ) { + const requestType = this.checker.getTypeFromTypeNode( + node.parameters[0].type + ) as ts.TypeReference + // TODO for now I'll use the type for validatedQuery until + // we have an actual approach to infer query types + const querySymbol = requestType.getProperty("validatedQuery") + if (querySymbol) { + const queryType = this.checker.getTypeOfSymbol(querySymbol) + queryType.getProperties().forEach((property) => { + const propertyType = this.checker.getTypeOfSymbol(property) + const descriptionOptions: SchemaDescriptionOptions = { + typeStr: property.getName(), + parentName: tagName, + node: property.valueDeclaration, + symbol: property, + nodeType: propertyType, + } + parameters.push( + this.getParameterObject({ + name: property.getName(), + type: "query", + description: this.getSchemaDescription(descriptionOptions), + required: this.isRequired(property), + schema: this.typeToSchema({ + itemType: propertyType, + title: property.getName(), + descriptionOptions, + }), + }) + ) + }) + } + + const requestTypeArguments = this.checker.getTypeArguments(requestType) + + if (requestTypeArguments.length === 1) { + requestSchema = this.typeToSchema({ + itemType: requestTypeArguments[0], + descriptionOptions: { + parentName: tagName, + }, + }) + } + } + + return { + queryParameters: parameters, + requestSchema, + } + } + + /** + * Retrieve the response's status. + * + * @param node - The node to retrieve its response status. + * @returns The response's status. + */ + getResponseStatus(node: FunctionNode): string { + let responseStatus = "200" + if ("body" in node && node.body && "statements" in node.body) { + node.body.statements.forEach((statement) => { + const matched = RES_STATUS_REGEX.exec(statement.getText())?.splice(1) + if (matched?.length === 1) { + responseStatus = matched[0] + } + }) + } + + return responseStatus + } + + /** + * Retrieve the response schema of the OAS operation. + * + * @param param0 - The OAS operation's details. + * @returns The response schema. + */ + getResponseSchema({ + node, + tagName, + }: { + /** + * The node to retrieve its response schema. + */ + node: FunctionNode + /** + * The tag's name. + */ + tagName?: string + }): OpenApiSchema | undefined { + let responseSchema: OpenApiSchema | undefined + + if ( + node.parameters[1].type && + ts.isTypeReferenceNode(node.parameters[1].type) + ) { + const responseType = this.checker.getTypeFromTypeNode( + node.parameters[1].type + ) as ts.TypeReference + const responseTypeArguments = + responseType.aliasTypeArguments || + this.checker.getTypeArguments(responseType) + + if (responseTypeArguments.length === 1) { + responseSchema = this.typeToSchema({ + itemType: responseTypeArguments[0], + descriptionOptions: { + parentName: tagName, + }, + }) + } + } + + return responseSchema + } + + /** + * Convert a TypeScript type to a schema object. + * + * @param param0 - The type and additional details. + * @returns The schema object. + */ + typeToSchema({ + itemType, + level = 1, + title, + descriptionOptions, + allowedChildren, + disallowedChildren, + }: { + /** + * The TypeScript type. + */ + itemType: ts.Type + /** + * The current level. Used to limit the recursive loop. + */ + level?: number + /** + * The type's title, if available. + */ + title?: string + /** + * options to retrieve a parameter/property's description. + */ + descriptionOptions?: Partial + /** + * Children that can be allowed to retrieve. If this property is supplied, + * only children in this array are added to the returned schema. + */ + allowedChildren?: string[] + /** + * Children that aren't allowed to retrieve. If this property is supplied, + * only children not included in this array are added to the schema. + */ + disallowedChildren?: string[] + }): OpenApiSchema { + if (level > this.MAX_LEVEL) { + return {} + } + + const symbol = itemType.aliasSymbol || itemType.symbol + const description = descriptionOptions?.typeStr + ? this.getSchemaDescription( + descriptionOptions as SchemaDescriptionOptions + ) + : title + ? this.getSchemaDescription({ typeStr: title, nodeType: itemType }) + : this.defaultSummary + const typeAsString = this.checker.typeToString(itemType) + + switch (true) { + case itemType.flags === ts.TypeFlags.Enum: + const enumMembers: string[] = [] + symbol?.members?.forEach((enumMember) => { + if ((enumMember.valueDeclaration as ts.EnumMember).initializer) { + enumMembers.push( + ( + enumMember.valueDeclaration as ts.EnumMember + ).initializer!.getText() + ) + } + }) + return { + type: "string", + enum: enumMembers, + } + case itemType.isLiteral(): + return { + type: + itemType.flags === ts.TypeFlags.StringLiteral + ? "string" + : itemType.flags === ts.TypeFlags.NumberLiteral + ? "number" + : "boolean", + title: title || typeAsString, + description, + format: this.getSchemaTypeFormat({ + typeName: typeAsString, + name: title, + }), + } + case itemType.flags === ts.TypeFlags.String || + itemType.flags === ts.TypeFlags.Number || + itemType.flags === ts.TypeFlags.Boolean || + typeAsString === "Date": + return { + type: + typeAsString === "Date" + ? "string" + : (typeAsString as OpenAPIV3.NonArraySchemaObjectType), + title: title || typeAsString, + description, + default: symbol?.valueDeclaration + ? this.getDefaultValue(symbol?.valueDeclaration) + : undefined, + format: this.getSchemaTypeFormat({ + typeName: typeAsString, + name: title, + }), + } + case "intrinsicName" in itemType && itemType.intrinsicName === "boolean": + return { + type: "boolean", + title: title || typeAsString, + description, + default: symbol?.valueDeclaration + ? this.getDefaultValue(symbol?.valueDeclaration) + : undefined, + } + case this.checker.isArrayType(itemType): + return { + type: "array", + description, + items: this.typeToSchema({ + itemType: this.checker.getTypeArguments( + itemType as ts.TypeReference + )[0], + // can't increment level because + // array must have items in it + level, + title, + descriptionOptions: + descriptionOptions || title + ? { + ...descriptionOptions, + parentName: title || descriptionOptions?.parentName, + } + : undefined, + }), + } + case itemType.isUnion(): + // if it's a union of literal types, + // consider it an enum + const allLiteral = (itemType as ts.UnionType).types.every((unionType) => + unionType.isLiteral() + ) + if (allLiteral) { + return { + type: "string", + enum: (itemType as ts.UnionType).types.map( + (unionType) => (unionType as ts.LiteralType).value + ), + } + } + return { + oneOf: (itemType as ts.UnionType).types.map((unionType) => + this.typeToSchema({ + itemType: unionType, + // not incrementing considering the + // current schema isn't actually a + // schema + level, + title, + descriptionOptions, + }) + ), + } + case itemType.isIntersection(): + return { + allOf: (itemType as ts.IntersectionType).types.map( + (intersectionType) => { + return this.typeToSchema({ + itemType: intersectionType, + // not incrementing considering the + // current schema isn't actually a + // schema + level, + title, + descriptionOptions, + }) + } + ), + } + case typeAsString.startsWith("Pick"): + const pickTypeArgs = + itemType.aliasTypeArguments || + this.checker.getTypeArguments(itemType as ts.TypeReference) + + if (pickTypeArgs.length < 2) { + return {} + } + const pickedProperties = pickTypeArgs[1].isUnion() + ? pickTypeArgs[1].types.map((unionType) => + this.getTypeName(unionType) + ) + : [this.getTypeName(pickTypeArgs[1])] + return this.typeToSchema({ + itemType: pickTypeArgs[0], + title, + level, + descriptionOptions, + allowedChildren: pickedProperties, + }) + case typeAsString.startsWith("Omit"): + const omitTypeArgs = + itemType.aliasTypeArguments || + this.checker.getTypeArguments(itemType as ts.TypeReference) + + if (omitTypeArgs.length < 2) { + return {} + } + const omitProperties = omitTypeArgs[1].isUnion() + ? omitTypeArgs[1].types.map((unionType) => + this.getTypeName(unionType) + ) + : [this.getTypeName(omitTypeArgs[1])] + return this.typeToSchema({ + itemType: omitTypeArgs[0], + title, + level, + descriptionOptions, + disallowedChildren: omitProperties, + }) + case typeAsString.startsWith("Partial"): + const typeArg = + itemType.aliasTypeArguments || + this.checker.getTypeArguments(itemType as ts.TypeReference) + if (!typeArg.length) { + return {} + } + + const schema = this.typeToSchema({ + itemType: typeArg[0], + title, + level, + descriptionOptions, + disallowedChildren, + allowedChildren, + }) + + // remove all required items + delete schema.required + + return schema + case itemType.isClassOrInterface() || + itemType.isTypeParameter() || + (itemType as ts.Type).flags === ts.TypeFlags.Object: + const properties: Record = {} + const requiredProperties: string[] = [] + if (level + 1 <= this.MAX_LEVEL) { + itemType.getProperties().forEach((property) => { + if ( + (allowedChildren && !allowedChildren.includes(property.name)) || + (disallowedChildren && disallowedChildren.includes(property.name)) + ) { + return + } + if (this.isRequired(property)) { + requiredProperties.push(property.name) + } + properties[property.name] = this.typeToSchema({ + itemType: this.checker.getTypeOfSymbol(property), + level: level + 1, + title: property.name, + descriptionOptions: { + ...descriptionOptions, + typeStr: property.name, + parentName: title || descriptionOptions?.parentName, + }, + }) + }) + } + const objSchema: OpenApiSchema = { + type: "object", + description, + "x-schemaName": + itemType.isClassOrInterface() || itemType.isTypeParameter() + ? this.oasSchemaHelper.normalizeSchemaName(typeAsString) + : undefined, + required: + requiredProperties.length > 0 ? requiredProperties : undefined, + properties, + } + + if (objSchema["x-schemaName"]) { + // add object to schemas to be created + // if necessary + this.oasSchemaHelper.schemaToReference(objSchema) + } + + return objSchema + default: + return {} + } + } + + /** + * Retrieve the description of a symbol, type, or node. Can be used to retrieve + * the description of a property or parameter in a schema. + * + * @param param0 - The details of the item to retrieve its description. + * @returns The description. + */ + getSchemaDescription({ + symbol, + node, + nodeType, + typeStr, + parentName, + }: SchemaDescriptionOptions): string { + if (!symbol && !node && !nodeType) { + // if none of the associated symbol, node, or type are provided, + // either retrieve the description from the knowledge base or use + // the default summary + return ( + this.knowledgeBaseFactory.tryToGetOasDescription({ + str: typeStr, + templateOptions: { + parentName, + }, + }) || this.defaultSummary + ) + } + + if (node) { + return this.getNodeSummary({ + node: node, + symbol, + nodeType, + }) + } + + let description = "" + + if (nodeType) { + description = this.getTypeDocBlock(nodeType) + } + + if (!description.length && symbol) { + description = this.getSymbolDocBlock(symbol) + } + + return description.length ? description : this.defaultSummary + } + + /** + * Check whether a symbol is required. + * + * @param symbol - The symbol to check. + * @returns Whether the symbol is required. + */ + isRequired(symbol: ts.Symbol): boolean { + let isRequired = true + const checkNode = (node: ts.Node) => { + if (node.kind === ts.SyntaxKind.QuestionToken) { + isRequired = false + } + + if (!isRequired) { + return + } + + node.forEachChild(checkNode) + } + symbol.valueDeclaration?.forEachChild(checkNode) + + return isRequired + } + + /** + * Retrieve the format of a property/parameter in a schema. + * + * @param param0 - The item's details. + * @returns The format, if available. + */ + getSchemaTypeFormat({ + typeName, + name, + }: { + typeName: string + name?: string + }): string | undefined { + switch (true) { + case typeName === "Date": + return "date-time" + case name?.includes("email"): + return "email" + case name?.includes("password"): + return "password" + } + } + + /** + * Retrieve the name of a type. This is useful when retrieving allowed/disallowed + * properties in an Omit/Pick type. + * + * @param itemType - The type to retrieve its name. + * @returns The type's name. + */ + getTypeName(itemType: ts.Type): string { + if (itemType.symbol || itemType.aliasSymbol) { + return (itemType.aliasSymbol || itemType.symbol).name + } + + if (itemType.isLiteral()) { + return itemType.value.toString() + } + + return this.checker.typeToString(itemType) + } + + /** + * Initialize the {@link tags} property. + */ + init() { + this.tags.set("admin", new Set()) + this.tags.set("store", new Set()) + } + + /** + * Update an array of parameters with a new one. + * + * @param param0 - The parameter details. + * @returns The updated parameters. + */ + updateParameters({ + oldParameters, + newParameters, + type, + }: { + /** + * The old list of parameters. + */ + oldParameters?: OpenAPIV3.ParameterObject[] + /** + * The new list of parameters. + */ + newParameters?: OpenAPIV3.ParameterObject[] + /** + * The type of parameters. + */ + type: ParameterType + }): OpenAPIV3.ParameterObject[] { + if (!oldParameters) { + return newParameters || [] + } + const oppositeParamType = type === "query" ? "path" : "query" + const oppositeParams: OpenAPIV3.ParameterObject[] = + oldParameters?.filter((param) => param.in === oppositeParamType) || [] + // check and update/add parameters if necessary + const existingParams: OpenAPIV3.ParameterObject[] = + oldParameters?.filter((param) => param.in === type) || [] + const paramsToRemove = new Set() + + existingParams.forEach((parameter) => { + const updatedParameter = newParameters?.find( + (newParam) => newParam.name === parameter.name + ) + if (!updatedParameter) { + // remove the parameter + paramsToRemove.add(parameter.name) + return + } + + if ( + updatedParameter.description !== parameter.description && + parameter.description === this.defaultSummary + ) { + parameter.description = updatedParameter.description + } + + if (updatedParameter.required !== parameter.required) { + parameter.required = updatedParameter.required + } + + if ( + (updatedParameter.schema as OpenApiSchema).type !== + (parameter.schema as OpenApiSchema).type + ) { + ;(parameter.schema as OpenApiSchema).type = ( + updatedParameter.schema as OpenApiSchema + ).type + } + + if ( + (updatedParameter.schema as OpenApiSchema).title !== + (parameter.schema as OpenApiSchema).title + ) { + ;(parameter.schema as OpenApiSchema).title = ( + updatedParameter.schema as OpenApiSchema + ).title + } + + if ( + (updatedParameter.schema as OpenApiSchema).description !== + (parameter.schema as OpenApiSchema).description && + (parameter.schema as OpenApiSchema).description === this.defaultSummary + ) { + ;(parameter.schema as OpenApiSchema).description = ( + updatedParameter.schema as OpenApiSchema + ).description + } + }) + + // find new parameters to add + newParameters?.forEach((parameter) => { + if (existingParams.some((newParam) => newParam.name === parameter.name)) { + return + } + + existingParams?.push(parameter) + }) + + // remove parameters no longer existing + return [ + ...oppositeParams, + ...(existingParams?.filter( + (parameter) => + (parameter as OpenAPIV3.ParameterObject).in === oppositeParamType || + !paramsToRemove.has((parameter as OpenAPIV3.ParameterObject).name) + ) || []), + ] + } + + /** + * Retrieve the updated schema. Can be used for request and response schemas. + * + * @param param0 - The schema details. + * @returns The updated schema. + */ + updateSchema({ + oldSchema, + newSchema, + }: { + /** + * The old schema. + */ + oldSchema?: OpenApiSchema | OpenAPIV3.ReferenceObject + /** + * The new schema. + */ + newSchema?: OpenApiSchema | OpenAPIV3.ReferenceObject + }): OpenApiSchema | undefined { + const oldSchemaObj = ( + oldSchema && "$ref" in oldSchema + ? this.oasSchemaHelper.getSchemaByName(oldSchema.$ref)?.schema + : oldSchema + ) as OpenApiSchema | undefined + const newSchemaObj = ( + newSchema && "$ref" in newSchema + ? this.oasSchemaHelper.getSchemaByName(newSchema.$ref)?.schema + : newSchema + ) as OpenApiSchema | undefined + if (!oldSchemaObj && newSchemaObj) { + return newSchemaObj + } else if (oldSchemaObj && !newSchemaObj) { + return undefined + } + + // update schema + if (oldSchemaObj!.type !== newSchemaObj?.type) { + oldSchemaObj!.type = newSchemaObj?.type + } + + if ( + oldSchemaObj!.description !== newSchemaObj?.description && + oldSchemaObj!.description === this.defaultSummary + ) { + oldSchemaObj!.description = + newSchemaObj?.description || this.defaultSummary + } + + oldSchemaObj!.required = newSchemaObj?.required + + if (oldSchemaObj!.type === "object") { + if (!oldSchemaObj?.properties && newSchemaObj?.properties) { + oldSchemaObj!.properties = newSchemaObj.properties + } else if (oldSchemaObj?.properties && !newSchemaObj?.properties) { + delete oldSchemaObj!.properties + } else { + // update existing properties + Object.entries(oldSchemaObj!.properties!).forEach( + ([propertyName, propertySchema]) => { + const newPropertySchemaKey = Object.keys( + newSchemaObj!.properties! + ).find((newPropertyName) => newPropertyName === propertyName) + if (!newPropertySchemaKey) { + // remove property + delete oldSchemaObj!.properties![propertyName] + return + } + + oldSchemaObj!.properties![propertyName] = + this.updateSchema({ + oldSchema: propertySchema as OpenApiSchema, + newSchema: newSchemaObj!.properties![ + propertyName + ] as OpenApiSchema, + }) || propertySchema + } + ) + // add new properties + Object.keys(newSchemaObj!.properties!) + .filter( + (propertyKey) => + !Object.hasOwn(oldSchemaObj!.properties!, propertyKey) + ) + .forEach((newPropertyKey) => { + oldSchemaObj!.properties![newPropertyKey] = + newSchemaObj!.properties![newPropertyKey] + }) + } + } else if ( + oldSchemaObj?.type === "array" && + newSchemaObj?.type === "array" + ) { + oldSchemaObj.items = + this.updateSchema({ + oldSchema: oldSchemaObj.items as OpenApiSchema, + newSchema: newSchemaObj!.items as OpenApiSchema, + }) || oldSchemaObj.items + } + + return oldSchemaObj + } + + /** + * Retrieve the file name that's used to write the OAS operation of a node. + * + * @param node - The node to retrieve its associated file name. + * @returns The file name. + */ + getAssociatedFileName(node: FunctionOrVariableNode): string { + const methodName = this.getHTTPMethodName(node) + const { oasPath } = this.getOasPath(node) + const area = oasPath.split("/")[0] + + const filename = `${methodName}_${oasPath.replaceAll("/", "_")}.ts` + + return join(this.baseOutputPath, "operations", area, filename) + } + + /** + * This method is executed when the {@link GeneratorEvent.FINISHED_GENERATE_EVENT} event is triggered. + * It writes new tags, if available, in base YAML. + */ + afterGenerate() { + this.writeNewTags("admin") + this.writeNewTags("store") + this.oasSchemaHelper.writeNewSchemas() + + // reset tags + this.init() + // reset schemas + this.oasSchemaHelper.init() + } + + /** + * Add new tags to the base YAML of an area. + * + * @param area - The area that the tag belongs to. + */ + writeNewTags(area: OasArea) { + // load base oas files + const areaYamlPath = join( + this.baseOutputPath, + "base-v2", + `${area}.oas.base.yaml` + ) + const areaYaml = parse( + readFileSync(areaYamlPath, "utf-8") + ) as OpenApiDocument + let addedTags = false + + areaYaml.tags = [...(areaYaml.tags || [])] + + this.tags.get(area)?.forEach((tag) => { + const existingTag = areaYaml.tags!.find((baseTag) => baseTag.name === tag) + if (!existingTag) { + // try to retrieve associated schema + const schema = this.oasSchemaHelper.getSchemaByName( + this.oasSchemaHelper.tagNameToSchemaName(tag) + ) + areaYaml.tags!.push({ + name: tag, + "x-associatedSchema": schema?.schema?.["x-schemaName"] + ? { + $ref: this.oasSchemaHelper.constructSchemaReference( + schema.schema["x-schemaName"] + ), + } + : undefined, + }) + addedTags = true + } + }) + + if (addedTags) { + // sort alphabetically + areaYaml.tags.sort((tagA, tagB) => { + return tagA.name.localeCompare(tagB.name) + }) + // write to the file + writeFileSync(areaYamlPath, stringify(areaYaml)) + } + } +} + +export default OasKindGenerator diff --git a/docs-util/packages/docblock-generator/src/classes/kinds/registry.ts b/docs-util/packages/docblock-generator/src/classes/kinds/registry.ts index e8d3f22172..1f0c9f5f66 100644 --- a/docs-util/packages/docblock-generator/src/classes/kinds/registry.ts +++ b/docs-util/packages/docblock-generator/src/classes/kinds/registry.ts @@ -1,9 +1,10 @@ import ts from "typescript" import FunctionKindGenerator from "./function.js" -import DefaultKindGenerator from "./default.js" +import DefaultKindGenerator, { GeneratorOptions } from "./default.js" import MedusaReactHooksKindGenerator from "./medusa-react-hooks.js" import SourceFileKindGenerator from "./source-file.js" import DTOPropertyGenerator from "./dto-property.js" +import OasKindGenerator from "./oas.js" /** * A class that is used as a registry for the kind generators. @@ -12,14 +13,20 @@ class KindsRegistry { protected kindInstances: DefaultKindGenerator[] protected defaultKindGenerator: DefaultKindGenerator - constructor(checker: ts.TypeChecker) { + constructor( + options: Pick< + GeneratorOptions, + "checker" | "generatorEventManager" | "additionalOptions" + > + ) { this.kindInstances = [ - new MedusaReactHooksKindGenerator({ checker }), - new FunctionKindGenerator({ checker }), - new SourceFileKindGenerator({ checker }), - new DTOPropertyGenerator({ checker }), + new OasKindGenerator(options), + new MedusaReactHooksKindGenerator(options), + new FunctionKindGenerator(options), + new SourceFileKindGenerator(options), + new DTOPropertyGenerator(options), ] - this.defaultKindGenerator = new DefaultKindGenerator({ checker }) + this.defaultKindGenerator = new DefaultKindGenerator(options) } /** diff --git a/docs-util/packages/docblock-generator/src/commands/clean-oas.ts b/docs-util/packages/docblock-generator/src/commands/clean-oas.ts new file mode 100644 index 0000000000..0f0ef4ff24 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/commands/clean-oas.ts @@ -0,0 +1,271 @@ +import path from "path" +import getOasOutputBasePath from "../utils/get-oas-output-base-path.js" +import { + existsSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from "fs" +import parseOas from "../utils/parse-oas.js" +import OasKindGenerator, { OasArea } from "../classes/kinds/oas.js" +import getMonorepoRoot from "../utils/get-monorepo-root.js" +import ts from "typescript" +import GeneratorEventManager from "../classes/helpers/generator-event-manager.js" +import { parse, stringify } from "yaml" +import OasSchemaHelper from "../classes/helpers/oas-schema.js" +import { DEFAULT_OAS_RESPONSES } from "../constants.js" +import { OpenApiDocument } from "../types/index.js" + +const OAS_PREFIX_REGEX = /@oas \[(?(get|post|delete))\] (?.+)/ + +export default async function () { + const oasOutputBasePath = getOasOutputBasePath() + const oasOperationsPath = path.join(oasOutputBasePath, "operations") + const apiRoutesPath = path.join( + getMonorepoRoot(), + "packages", + "medusa", + "src", + "api-v2" + ) + const areas: OasArea[] = ["admin", "store"] + const tags: Map> = new Map() + const oasSchemaHelper = new OasSchemaHelper() + const referencedSchemas: Set = new Set() + const allSchemas: Set = new Set() + areas.forEach((area) => { + tags.set(area, new Set()) + }) + + console.log("Cleaning OAS files...") + + // read files under the operations/{area} directory + areas.forEach((area) => { + const areaPath = path.join(oasOperationsPath, area) + if (!existsSync(areaPath)) { + return + } + + readdirSync(areaPath, { + recursive: true, + encoding: "utf-8", + }).forEach((oasFile) => { + const filePath = path.join(areaPath, oasFile) + const { oas, oasPrefix } = parseOas(readFileSync(filePath, "utf-8")) || {} + + if (!oas || !oasPrefix) { + return + } + + // decode oasPrefix + const matchOasPrefix = OAS_PREFIX_REGEX.exec(oasPrefix) + if (!matchOasPrefix?.groups?.method || !matchOasPrefix.groups.path) { + return + } + const splitPath = matchOasPrefix.groups.path.substring(1).split("/") + + // normalize path by replacing {paramName} with [paramName] + const normalizedOasPrefix = splitPath + .map((item) => item.replace(/^\{(.+)\}$/, "[$1]")) + .join("/") + const sourceFilePath = path.join( + apiRoutesPath, + normalizedOasPrefix, + "route.ts" + ) + + // check if a route exists for the path + if (!existsSync(sourceFilePath)) { + // remove OAS file + rmSync(filePath, { + force: true, + }) + return + } + + // check if method exists in the file + let exists = false + const program = ts.createProgram([sourceFilePath], {}) + + const oasKindGenerator = new OasKindGenerator({ + checker: program.getTypeChecker(), + generatorEventManager: new GeneratorEventManager(), + additionalOptions: {}, + }) + const sourceFile = program.getSourceFile(sourceFilePath) + + if (!sourceFile) { + // remove file + rmSync(filePath, { + force: true, + }) + return + } + + const visitChildren = (node: ts.Node) => { + if ( + !exists && + oasKindGenerator.isAllowed(node) && + oasKindGenerator.canDocumentNode(node) && + oasKindGenerator.getHTTPMethodName(node) === + matchOasPrefix.groups!.method + ) { + exists = true + } else if (!exists) { + ts.forEachChild(node, visitChildren) + } + } + + ts.forEachChild(sourceFile, visitChildren) + + if (!exists) { + // remove OAS file + rmSync(filePath, { + force: true, + }) + return + } + + // collect tags + oas.tags?.forEach((tag) => { + const areaTags = tags.get(area as OasArea) + areaTags?.add(tag) + }) + + // collect schemas + if (oas.requestBody) { + if (oasSchemaHelper.isRefObject(oas.requestBody)) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(oas.requestBody.$ref) + ) + } else { + const requestBodySchema = + oas.requestBody.content[Object.keys(oas.requestBody.content)[0]] + .schema + if (oasSchemaHelper.isRefObject(requestBodySchema)) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(requestBodySchema.$ref) + ) + } + } + } + + if (oas.responses) { + const successResponseKey = Object.keys(oas.responses)[0] + if (!Object.keys(DEFAULT_OAS_RESPONSES).includes(successResponseKey)) { + const responseObj = oas.responses[successResponseKey] + if (oasSchemaHelper.isRefObject(responseObj)) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(responseObj.$ref) + ) + } else if (responseObj.content) { + const responseBodySchema = + responseObj.content[Object.keys(responseObj.content)[0]].schema + if (oasSchemaHelper.isRefObject(responseBodySchema)) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(responseBodySchema.$ref) + ) + } + } + } + } + }) + }) + + console.log("Clean tags...") + + // check if any tags should be removed + const oasBasePath = path.join(oasOutputBasePath, "base-v2") + readdirSync(oasBasePath, { + recursive: true, + encoding: "utf-8", + }).forEach((baseYaml) => { + const baseYamlPath = path.join(oasBasePath, baseYaml) + const parsedBaseYaml = parse( + readFileSync(baseYamlPath, "utf-8") + ) as OpenApiDocument + + const area = path.basename(baseYaml).split(".")[0] as OasArea + const areaTags = tags.get(area) + if (!areaTags) { + return + } + const lengthBefore = parsedBaseYaml.tags?.length || 0 + + parsedBaseYaml.tags = parsedBaseYaml.tags?.filter((tag) => + areaTags.has(tag.name) + ) + + if (lengthBefore !== (parsedBaseYaml.tags?.length || 0)) { + // sort alphabetically + parsedBaseYaml.tags?.sort((tagA, tagB) => { + return tagA.name.localeCompare(tagB.name) + }) + // write to the file + writeFileSync(baseYamlPath, stringify(parsedBaseYaml)) + } + + // collect referenced schemas + parsedBaseYaml.tags?.forEach((tag) => { + if (tag["x-associatedSchema"]) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(tag["x-associatedSchema"].$ref) + ) + } + }) + }) + + console.log("Clean schemas...") + + // check if any schemas should be removed + // a schema is removed if no other schemas/operations reference it + const oasSchemasPath = path.join(oasOutputBasePath, "schemas") + readdirSync(oasSchemasPath, { + recursive: true, + encoding: "utf-8", + }).forEach((schemaYaml) => { + const schemaPath = path.join(oasSchemasPath, schemaYaml) + const parsedSchema = oasSchemaHelper.parseSchema( + readFileSync(schemaPath, "utf-8") + ) + + if (!parsedSchema) { + // remove file + rmSync(schemaPath, { + force: true, + }) + return + } + + // add schema to all schemas + if (parsedSchema.schema["x-schemaName"]) { + allSchemas.add(parsedSchema.schema["x-schemaName"]) + } + + // collect referenced schemas + if (parsedSchema.schema.properties) { + Object.values(parsedSchema.schema.properties).forEach((property) => { + if (oasSchemaHelper.isRefObject(property)) { + referencedSchemas.add( + oasSchemaHelper.normalizeSchemaName(property.$ref) + ) + } + }) + } + }) + + // clean up schemas + allSchemas.forEach((schemaName) => { + if (referencedSchemas.has(schemaName)) { + return + } + + // schema isn't referenced anywhere, so remove it + rmSync(path.join(oasSchemasPath, `${schemaName}.ts`), { + force: true, + }) + }) + + console.log("Finished clean up") +} diff --git a/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts b/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts index b46164d540..78818f6d81 100644 --- a/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts +++ b/docs-util/packages/docblock-generator/src/commands/run-git-changes.ts @@ -1,9 +1,14 @@ import path from "path" -import DocblockGenerator from "../classes/docblock-generator.js" +import DocblockGenerator from "../classes/generators/docblock.js" import getMonorepoRoot from "../utils/get-monorepo-root.js" -import { GitManager } from "../classes/git-manager.js" +import { GitManager } from "../classes/helpers/git-manager.js" +import { CommonCliOptions } from "../types/index.js" +import OasGenerator from "../classes/generators/oas.js" -export default async function runGitChanges() { +export default async function runGitChanges({ + type, + ...options +}: CommonCliOptions) { const monorepoPath = getMonorepoRoot() // retrieve the changed files under `packages` in the monorepo root. const gitManager = new GitManager() @@ -20,12 +25,23 @@ export default async function runGitChanges() { files = files.map((filePath) => path.resolve(monorepoPath, filePath)) - // generate docblocks for each of the files. - const docblockGenerator = new DocblockGenerator({ - paths: files, - }) + if (type === "all" || type === "docs") { + const docblockGenerator = new DocblockGenerator({ + paths: files, + ...options, + }) - await docblockGenerator.run() + await docblockGenerator.run() + } + + if (type === "all" || type === "oas") { + const oasGenerator = new OasGenerator({ + paths: files, + ...options, + }) + + oasGenerator.run() + } console.log(`Finished generating docs for ${files.length} files.`) } diff --git a/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts b/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts index cb32bb21a9..a88034d413 100644 --- a/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts +++ b/docs-util/packages/docblock-generator/src/commands/run-git-commit.ts @@ -1,10 +1,15 @@ import filterFiles from "../utils/filter-files.js" import path from "path" import getMonorepoRoot from "../utils/get-monorepo-root.js" -import DocblockGenerator from "../classes/docblock-generator.js" -import { GitManager } from "../classes/git-manager.js" +import DocblockGenerator from "../classes/generators/docblock.js" +import OasGenerator from "../classes/generators/oas.js" +import { CommonCliOptions } from "../types/index.js" +import { GitManager } from "../classes/helpers/git-manager.js" -export default async function (commitSha: string) { +export default async function ( + commitSha: string, + { type, ...options }: CommonCliOptions +) { const monorepoPath = getMonorepoRoot() // retrieve the files changed in the commit const gitManager = new GitManager() @@ -28,11 +33,23 @@ export default async function (commitSha: string) { ) // generate docblocks for each of the files. - const docblockGenerator = new DocblockGenerator({ - paths: filteredFiles, - }) + if (type === "all" || type === "docs") { + const docblockGenerator = new DocblockGenerator({ + paths: filteredFiles, + ...options, + }) - await docblockGenerator.run() + await docblockGenerator.run() + } + + if (type === "all" || type === "oas") { + const oasGenerator = new OasGenerator({ + paths: filteredFiles, + ...options, + }) + + oasGenerator.run() + } console.log(`Finished generating docs for ${filteredFiles.length} files.`) } diff --git a/docs-util/packages/docblock-generator/src/commands/run-release.ts b/docs-util/packages/docblock-generator/src/commands/run-release.ts index 94c6cc68c1..62df087bea 100644 --- a/docs-util/packages/docblock-generator/src/commands/run-release.ts +++ b/docs-util/packages/docblock-generator/src/commands/run-release.ts @@ -1,14 +1,12 @@ import filterFiles from "../utils/filter-files.js" import path from "path" -import DocblockGenerator from "../classes/docblock-generator.js" +import DocblockGenerator from "../classes/generators/docblock.js" import getMonorepoRoot from "../utils/get-monorepo-root.js" -import { GitManager } from "../classes/git-manager.js" +import { GitManager } from "../classes/helpers/git-manager.js" +import OasGenerator from "../classes/generators/oas.js" +import { CommonCliOptions } from "../types/index.js" -type Options = { - tag?: string -} - -export default async function ({ tag }: Options) { +export default async function ({ type, tag, ...options }: CommonCliOptions) { const gitManager = new GitManager() console.log(`Get files in commits since ${tag || "last release"}`) @@ -33,12 +31,23 @@ export default async function ({ tag }: Options) { path.resolve(getMonorepoRoot(), filePath) ) - // generate docblocks for each of the files. - const docblockGenerator = new DocblockGenerator({ - paths: filteredFiles, - }) + if (type === "all" || type === "docs") { + const docblockGenerator = new DocblockGenerator({ + paths: filteredFiles, + ...options, + }) - await docblockGenerator.run() + await docblockGenerator.run() + } + + if (type === "all" || type === "oas") { + const oasGenerator = new OasGenerator({ + paths: filteredFiles, + ...options, + }) + + oasGenerator.run() + } console.log(`Finished generating docs for ${filteredFiles.length} files.`) } diff --git a/docs-util/packages/docblock-generator/src/commands/run.ts b/docs-util/packages/docblock-generator/src/commands/run.ts index 1349f4341a..b7689f6d57 100644 --- a/docs-util/packages/docblock-generator/src/commands/run.ts +++ b/docs-util/packages/docblock-generator/src/commands/run.ts @@ -1,17 +1,31 @@ -import DocblockGenerator, { Options } from "../classes/docblock-generator.js" +import DocblockGenerator from "../classes/generators/docblock.js" +import { Options } from "../classes/generators/index.js" +import OasGenerator from "../classes/generators/oas.js" +import { CommonCliOptions } from "../types/index.js" export default async function run( paths: string[], - options: Omit + { type, ...options }: Omit & CommonCliOptions ) { console.log("Running...") - const docblockGenerator = new DocblockGenerator({ - paths, - ...options, - }) + if (type === "all" || type === "docs") { + const docblockGenerator = new DocblockGenerator({ + paths, + ...options, + }) - await docblockGenerator.run() + await docblockGenerator.run() + } + + if (type === "all" || type === "oas") { + const oasGenerator = new OasGenerator({ + paths, + ...options, + }) + + oasGenerator.run() + } console.log(`Finished running.`) } diff --git a/docs-util/packages/docblock-generator/src/constants.ts b/docs-util/packages/docblock-generator/src/constants.ts index 17fa477d58..66d9cb1d25 100644 --- a/docs-util/packages/docblock-generator/src/constants.ts +++ b/docs-util/packages/docblock-generator/src/constants.ts @@ -1,4 +1,29 @@ -export const DOCBLOCK_NEW_LINE = "\n * " +import { OpenAPIV3 } from "openapi-types" + +export const DOCBLOCK_LINE_ASTRIX = " * " +export const DOCBLOCK_NEW_LINE = `\n${DOCBLOCK_LINE_ASTRIX}` export const DOCBLOCK_START = `*${DOCBLOCK_NEW_LINE}` export const DOCBLOCK_END_LINE = "\n" export const DOCBLOCK_DOUBLE_LINES = `${DOCBLOCK_NEW_LINE}${DOCBLOCK_NEW_LINE}` +export const DEFAULT_OAS_RESPONSES: { + [k: string]: OpenAPIV3.ReferenceObject +} = { + "400": { + $ref: "#/components/responses/400_error", + }, + "401": { + $ref: "#/components/responses/unauthorized", + }, + "404": { + $ref: "#/components/responses/not_found_error", + }, + "409": { + $ref: "#/components/responses/invalid_state_error", + }, + "422": { + $ref: "#/components/responses/invalid_request_error", + }, + "500": { + $ref: "#/components/responses/500_error", + }, +} diff --git a/docs-util/packages/docblock-generator/src/index.ts b/docs-util/packages/docblock-generator/src/index.ts index 8620e64518..fda2f90ef5 100644 --- a/docs-util/packages/docblock-generator/src/index.ts +++ b/docs-util/packages/docblock-generator/src/index.ts @@ -1,15 +1,26 @@ #!/usr/bin/env node import "dotenv/config" -import { Command } from "commander" +import { Command, Option } from "commander" import run from "./commands/run.js" import runGitChanges from "./commands/run-git-changes.js" import runGitCommit from "./commands/run-git-commit.js" import runRelease from "./commands/run-release.js" +import cleanOas from "./commands/clean-oas.js" const program = new Command() program.name("docblock-generator").description("Generate TSDoc doc-blocks") +// define common options +const typeOption = new Option("--type ", "The type of docs to generate.") + .choices(["all", "docs", "oas"]) + .default("all") + +const generateExamplesOption = new Option( + "--generate-examples", + "Whether to generate examples" +).default(false) + program .command("run") .description("Generate TSDoc doc-blocks for specified files.") @@ -18,17 +29,23 @@ program "--dry-run", "Whether to run the command without writing the changes." ) + .addOption(typeOption) + .addOption(generateExamplesOption) .action(run) program .command("run:changes") .description("Generate TSDoc doc-blocks for changed files in git.") + .addOption(typeOption) + .addOption(generateExamplesOption) .action(runGitChanges) program .command("run:commit") .description("Generate TSDoc doc-blocks for changed files in a commit.") .argument("", "The SHA of a commit.") + .addOption(typeOption) + .addOption(generateExamplesOption) .action(runGitCommit) program @@ -36,10 +53,19 @@ program .description( "Generate TSDoc doc-blocks for files part of the latest release. It will retrieve the files of commits between the latest two releases." ) + .addOption(typeOption) + .addOption(generateExamplesOption) .option( "--tag ", "Specify a release tag to use rather than the latest release." ) .action(runRelease) +program + .command("clean:oas") + .description( + "Check generated OAS under the `oas-output/operations` directory and remove any OAS that no longer exists." + ) + .action(cleanOas) + program.parse() diff --git a/docs-util/packages/docblock-generator/src/types/index.d.ts b/docs-util/packages/docblock-generator/src/types/index.d.ts new file mode 100644 index 0000000000..b40a2c743e --- /dev/null +++ b/docs-util/packages/docblock-generator/src/types/index.d.ts @@ -0,0 +1,30 @@ +import { OpenAPIV3 } from "openapi-types" + +declare type CodeSample = { + lang: string + label: string + source: string +} + +export declare type OpenApiOperation = Partial & { + "x-authenticated"?: boolean + "x-codeSamples"?: CodeSample[] +} + +export declare type CommonCliOptions = { + type: "all" | "oas" | "docs" + generateExamples?: boolean + tag?: string +} + +export declare type OpenApiSchema = OpenAPIV3.SchemaObject & { + "x-schemaName"?: string +} + +export declare interface OpenApiTagObject extends OpenAPIV3.TagObject { + "x-associatedSchema"?: OpenAPIV3.ReferenceObject +} + +export declare interface OpenApiDocument extends OpenAPIV3.Document { + tags?: OpenApiTagObject[] +} diff --git a/docs-util/packages/docblock-generator/src/utils/format-oas.ts b/docs-util/packages/docblock-generator/src/utils/format-oas.ts new file mode 100644 index 0000000000..8e7522881e --- /dev/null +++ b/docs-util/packages/docblock-generator/src/utils/format-oas.ts @@ -0,0 +1,20 @@ +import { stringify } from "yaml" +import { DOCBLOCK_END_LINE, DOCBLOCK_NEW_LINE } from "../constants.js" +import { OpenApiOperation, OpenApiSchema } from "../types/index.js" + +/** + * Retrieve the OAS as a formatted string that can be used as a comment. + * + * @param oas - The OAS operation to format. + * @param oasPrefix - The OAS prefix that's used before the OAS operation. + * @returns The formatted OAS comment. + */ +export default function formatOas( + oas: OpenApiOperation | OpenApiSchema, + oasPrefix: string +) { + return `* ${oasPrefix}${DOCBLOCK_NEW_LINE}${stringify(oas).replaceAll( + "\n", + DOCBLOCK_NEW_LINE + )}${DOCBLOCK_END_LINE}` +} diff --git a/docs-util/packages/docblock-generator/src/utils/get-oas-output-base-path.ts b/docs-util/packages/docblock-generator/src/utils/get-oas-output-base-path.ts new file mode 100644 index 0000000000..e33f41de58 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/utils/get-oas-output-base-path.ts @@ -0,0 +1,6 @@ +import path from "path" +import getMonorepoRoot from "./get-monorepo-root.js" + +export default function getOasOutputBasePath() { + return path.join(getMonorepoRoot(), "docs-util", "oas-output") +} diff --git a/docs-util/packages/docblock-generator/src/utils/node-has-comments.ts b/docs-util/packages/docblock-generator/src/utils/node-has-comments.ts deleted file mode 100644 index 5376e9f0cb..0000000000 --- a/docs-util/packages/docblock-generator/src/utils/node-has-comments.ts +++ /dev/null @@ -1,16 +0,0 @@ -import ts from "typescript" - -/** - * Checks whether a node has comments. - * - * @param {ts.Node} node - The node to check. - * @returns {boolean} Whether the node has comments. - */ -export default function nodeHasComments(node: ts.Node): boolean { - return ( - ts.getLeadingCommentRanges( - node.getSourceFile().getFullText(), - node.getFullStart() - ) !== undefined - ) -} diff --git a/docs-util/packages/docblock-generator/src/utils/parse-oas.ts b/docs-util/packages/docblock-generator/src/utils/parse-oas.ts new file mode 100644 index 0000000000..3e0c821306 --- /dev/null +++ b/docs-util/packages/docblock-generator/src/utils/parse-oas.ts @@ -0,0 +1,42 @@ +import { parse } from "yaml" +import { OpenApiOperation } from "../types/index.js" +import { DOCBLOCK_LINE_ASTRIX } from "../constants.js" + +export type ExistingOas = { + oas: OpenApiOperation + oasPrefix: string +} + +export default function parseOas(content: string): ExistingOas | undefined { + content = content + .replace(`/**\n`, "") + .replaceAll(DOCBLOCK_LINE_ASTRIX, "") + .replaceAll("*/", "") + .trim() + + if (!content.startsWith("@oas")) { + // the file is of an invalid format. + return + } + + // extract oas prefix line + const splitNodeComments = content.split("\n") + const oasPrefix = content.split("\n")[0] + content = splitNodeComments.slice(1).join("\n") + + let oas: OpenApiOperation | undefined + + try { + oas = parse(content) as OpenApiOperation + } catch (e) { + // couldn't parse the OAS, so consider it + // not existent + } + + return oas + ? { + oas, + oasPrefix, + } + : undefined +} diff --git a/docs-util/packages/docblock-generator/src/utils/str-formatting.ts b/docs-util/packages/docblock-generator/src/utils/str-formatting.ts index 1a96a52a93..15d6ba866b 100644 --- a/docs-util/packages/docblock-generator/src/utils/str-formatting.ts +++ b/docs-util/packages/docblock-generator/src/utils/str-formatting.ts @@ -2,6 +2,12 @@ export function capitalize(str: string): string { return `${str.charAt(0).toUpperCase()}${str.substring(1).toLowerCase()}` } +export function wordsToCamel(str: string): string { + return `${str.charAt(0).toLowerCase()}${str + .substring(1) + .replaceAll(/\s([a-zA-Z])/g, (captured) => captured.toUpperCase())}` +} + export function camelToWords(str: string): string { return str .replaceAll(/([A-Z])/g, " $1") @@ -23,6 +29,39 @@ export function snakeToWords(str: string): string { return str.replaceAll("_", " ").toLowerCase() } +export function kebabToTitle(str: string): string { + return str + .split("-") + .map((word) => capitalize(word)) + .join(" ") +} + +export function kebabToCamel(str: string): string { + return str + .split("-") + .map((word, index) => { + if (index === 0) { + return word + } + return `${word.charAt(0).toUpperCase()}${word.substring(1)}` + }) + .join("") +} + +export function wordsToKebab(str: string): string { + return str + .split(" ") + .map((word) => word.toLowerCase()) + .join("-") +} + +export function wordsToPascal(str: string): string { + return str + .split(" ") + .map((word) => capitalize(word)) + .join("") +} + /** * Remove parts of the name such as DTO, Filterable, etc... * diff --git a/docs-util/yarn.lock b/docs-util/yarn.lock index 585932935a..9426e7ebb9 100644 --- a/docs-util/yarn.lock +++ b/docs-util/yarn.lock @@ -450,6 +450,13 @@ __metadata: languageName: node linkType: hard +"@faker-js/faker@npm:^8.4.0": + version: 8.4.0 + resolution: "@faker-js/faker@npm:8.4.0" + checksum: 2dd9a3f6a38a70baa8d8ae222d9d371eea2b56eba4bd12bb138230e9481687686330fddee7581d3f285cf8ea8fe1534e08e43a110de8743e561a6eccc3fdc670 + languageName: node + linkType: hard + "@fastify/busboy@npm:^2.0.0": version: 2.1.0 resolution: "@fastify/busboy@npm:2.1.0" @@ -2213,15 +2220,20 @@ __metadata: version: 0.0.0-use.local resolution: "docblock-generator@workspace:packages/docblock-generator" dependencies: + "@faker-js/faker": ^8.4.0 "@octokit/core": ^5.0.2 "@types/node": ^20.9.4 commander: ^11.1.0 dotenv: ^16.3.1 eslint: ^8.56.0 minimatch: ^9.0.3 + openapi-types: ^12.1.3 + pluralize: ^8.0.0 + prettier: ^3.2.4 ts-node: ^10.9.1 typescript: 5.2 utils: "*" + yaml: ^2.3.4 bin: workflow-diagrams-generator: dist/index.js languageName: unknown @@ -3863,6 +3875,13 @@ __metadata: languageName: node linkType: hard +"openapi-types@npm:^12.1.3": + version: 12.1.3 + resolution: "openapi-types@npm:12.1.3" + checksum: 4ad4eb91ea834c237edfa6ab31394e87e00c888fc2918009763389c00d02342345195d6f302d61c3fd807f17723cd48df29b47b538b68375b3827b3758cd520f + languageName: node + linkType: hard + "optionator@npm:^0.9.3": version: 0.9.3 resolution: "optionator@npm:0.9.3" @@ -4127,6 +4146,13 @@ __metadata: languageName: node linkType: hard +"pluralize@npm:^8.0.0": + version: 8.0.0 + resolution: "pluralize@npm:8.0.0" + checksum: 2044cfc34b2e8c88b73379ea4a36fc577db04f651c2909041b054c981cd863dd5373ebd030123ab058d194ae615d3a97cfdac653991e499d10caf592e8b3dc33 + languageName: node + linkType: hard + "pony-cause@npm:^2.1.2": version: 2.1.10 resolution: "pony-cause@npm:2.1.10" @@ -4189,6 +4215,15 @@ __metadata: languageName: node linkType: hard +"prettier@npm:^3.2.4": + version: 3.2.4 + resolution: "prettier@npm:3.2.4" + bin: + prettier: bin/prettier.cjs + checksum: 88dfeb78ac6096522c9a5b81f1413d875f568420d9bb6a5e5103527912519b993f2bcdcac311fcff5718d5869671d44e4f85827d3626f3a6ce32b9abc65d88e0 + languageName: node + linkType: hard + "process-nextick-args@npm:~2.0.0": version: 2.0.1 resolution: "process-nextick-args@npm:2.0.1" @@ -5441,7 +5476,7 @@ __metadata: languageName: node linkType: hard -"yaml@npm:^2.3.3": +"yaml@npm:^2.3.3, yaml@npm:^2.3.4": version: 2.3.4 resolution: "yaml@npm:2.3.4" checksum: cf03b68f8fef5e8516b0f0b54edaf2459f1648317fc6210391cf606d247e678b449382f4bd01f77392538429e306c7cba8ff46ff6b37cac4de9a76aff33bd9e1 diff --git a/packages/medusa/src/types/common.ts b/packages/medusa/src/types/common.ts index 47a1263b61..707c537cde 100644 --- a/packages/medusa/src/types/common.ts +++ b/packages/medusa/src/types/common.ts @@ -119,19 +119,19 @@ export type QueryConfig = { */ export type RequestQueryFields = { /** - * {@inheritDoc FindParams.expand} + * Comma-separated relations that should be expanded in the returned data. */ expand?: string /** - * {@inheritDoc FindParams.fields} + * Comma-separated fields that should be included in the returned data. */ fields?: string /** - * {@inheritDoc FindPaginationParams.offset} + * The number of items to skip when retrieving a list. */ offset?: number /** - * {@inheritDoc FindPaginationParams.limit} + * Limit the number of items returned in the list. */ limit?: number /** @@ -510,14 +510,14 @@ export class AddressCreatePayload { */ export class FindParams { /** - * Comma-separated relations that should be expanded in the returned data. + * {@inheritDoc RequestQueryFields.expand} */ @IsString() @IsOptional() expand?: string /** - * Comma-separated fields that should be included in the returned data. + * {@inheritDoc RequestQueryFields.fields} */ @IsString() @IsOptional() @@ -529,7 +529,7 @@ export class FindParams { */ export class FindPaginationParams { /** - * The number of items to skip when retrieving a list. + * {@inheritDoc RequestQueryFields.offset} * @defaultValue 0 */ @IsNumber() @@ -538,7 +538,7 @@ export class FindPaginationParams { offset?: number = 0 /** - * Limit the number of items returned in the list. + * {@inheritDoc RequestQueryFields.limit} * @defaultValue 20 */ @IsNumber() diff --git a/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts b/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts index 224cce1ef5..f0b0a68e66 100644 --- a/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts +++ b/packages/oas/medusa-oas-cli/src/__tests__/command-oas.test.ts @@ -11,6 +11,16 @@ import execa from "execa" const medusaPackagePath = path.dirname( require.resolve("@medusajs/medusa/package.json") ) +/** + * OAS output directory + * + * @privateRemark + * This should be the only directory OAS is loaded from for Medusa V2. + * For now, we only use it if the --v2 flag it passed to the CLI tool. + */ +const oasOutputPath = path.resolve( + __dirname, "..", "..", "..", "..", "docs-util", "oas-output" +) const basePath = path.resolve(__dirname, `../../`) export const runCLI = async (command: string, options: string[] = []) => { @@ -80,8 +90,8 @@ describe("command oas", () => { it("generates oas using admin.oas.base.yaml", async () => { const yamlFilePath = path.resolve( - medusaPackagePath, - "oas", + oasOutputPath, + "base", "admin.oas.base.yaml" ) const oasBase = (await readYaml(yamlFilePath)) as OpenAPIObject @@ -107,8 +117,8 @@ describe("command oas", () => { it("generates oas using store.oas.base.yaml", async () => { const yamlFilePath = path.resolve( - medusaPackagePath, - "oas", + oasOutputPath, + "base", "store.oas.base.yaml" ) const oasBase = (await readYaml(yamlFilePath)) as OpenAPIObject diff --git a/packages/oas/medusa-oas-cli/src/command-oas.ts b/packages/oas/medusa-oas-cli/src/command-oas.ts index 56a36b0a46..881bdbd4a5 100644 --- a/packages/oas/medusa-oas-cli/src/command-oas.ts +++ b/packages/oas/medusa-oas-cli/src/command-oas.ts @@ -26,6 +26,16 @@ const medusaTypesPath = path.dirname( const medusaUtilsPath = path.dirname( require.resolve("@medusajs/utils/package.json") ) +/** + * OAS output directory + * + * @privateRemark + * This should be the only directory OAS is loaded from for Medusa V2. + * For now, we only use it if the --v2 flag it passed to the CLI tool. + */ +const oasOutputPath = path.resolve( + __dirname, "..", "..", "..", "..", "docs-util", "oas-output" +) const basePath = path.resolve(__dirname, "../") /** @@ -53,6 +63,10 @@ export const commandOptions: Option[] = [ "Custom base OAS file to use for swagger-inline." ), new Option("-F, --force", "Ignore OAS validation and output OAS files."), + new Option( + "--v2", + "Generate OAS files for V2 endpoints. This loads OAS from docs-util/oas-output/operations directory" + ) ] export function getCommand() { @@ -75,6 +89,7 @@ export async function execute(cliParams: OptionValues) { */ const dryRun = !!cliParams.dryRun const force = !!cliParams.force + const v2 = !!cliParams.v2 const apiType: ApiType = cliParams.type @@ -107,11 +122,11 @@ export async function execute(cliParams: OptionValues) { console.log(`🟣 Generating OAS - ${apiType}`) if (apiType === "combined") { - const adminOAS = await getOASFromCodebase("admin") - const storeOAS = await getOASFromCodebase("store") + const adminOAS = await getOASFromCodebase("admin", undefined, v2) + const storeOAS = await getOASFromCodebase("store", undefined, v2) oas = await combineOAS(adminOAS, storeOAS) } else { - oas = await getOASFromCodebase(apiType) + oas = await getOASFromCodebase(apiType, undefined, v2) } if (additionalPaths.length || baseFile) { @@ -137,10 +152,17 @@ export async function execute(cliParams: OptionValues) { */ async function getOASFromCodebase( apiType: ApiType, - customBaseFile?: string + customBaseFile?: string, + v2?: boolean ): Promise { const gen = await swaggerInline( - [ + v2 ? [ + path.resolve(oasOutputPath, "operations", apiType), + path.resolve(oasOutputPath, "schemas"), + // We currently load error schemas from here. If we change + // that in the future, we should change the path. + path.resolve(medusaPackagePath, "dist", "api/middlewares"), + ] : [ path.resolve(medusaTypesPath, "dist"), path.resolve(medusaUtilsPath, "dist"), path.resolve(medusaPackagePath, "dist", "models"), @@ -151,7 +173,7 @@ async function getOASFromCodebase( { base: customBaseFile ?? - path.resolve(medusaPackagePath, "oas", `${apiType}.oas.base.yaml`), + path.resolve(oasOutputPath, v2 ? "base-v2" : "base", `${apiType}.oas.base.yaml`), format: ".json", } ) diff --git a/packages/oas/oas-github-ci/scripts/build-openapi.js b/packages/oas/oas-github-ci/scripts/build-openapi.js index f55852215f..d839e3a8b8 100755 --- a/packages/oas/oas-github-ci/scripts/build-openapi.js +++ b/packages/oas/oas-github-ci/scripts/build-openapi.js @@ -7,6 +7,7 @@ const execa = require("execa") const isDryRun = process.argv.indexOf("--dry-run") !== -1 const withFullFile = process.argv.indexOf("--with-full-file") !== -1 +const v2 = process.argv.indexOf("--v2") !== -1 const basePath = path.resolve(__dirname, `../`) const repoRootPath = path.resolve(basePath, `../../../`) const docsApiPath = path.resolve(repoRootPath, "www/apps/api-reference/specs") @@ -22,9 +23,13 @@ const run = async () => { } const generateOASSource = async (outDir, apiType) => { + const commandParams = ["oas", `--type=${apiType}`, `--out-dir=${outDir}`] + if (v2) { + commandParams.push(`--v2`) + } const { all: logs } = await execa( "medusa-oas", - ["oas", `--type=${apiType}`, `--out-dir=${outDir}`], + commandParams, { cwd: basePath, all: true } ) console.log(logs)