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<ResponseType>
) => {
  // ...
}
```

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<T = unknown> extends Request {
  user?: (User | Customer) & { customer_id?: string; userId?: string }
  scope: MedusaContainer
}

export type MedusaResponse<T = unknown> = Response

export type MedusaNextFunction = NextFunction

export type MedusaRequestHandler = (
  req: MedusaRequest,
  res: MedusaResponse,
  next: MedusaNextFunction
) => Promise<void> | 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<ResponseType>
) => {
  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<ResponseType>
) => {
  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.
This commit is contained in:
Shahed Nasser
2024-02-13 10:40:04 +02:00
committed by GitHub
parent a86c87fe14
commit 374a3f4dab
41 changed files with 4677 additions and 312 deletions

View File

@@ -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

View File

@@ -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/**
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/**

View File

@@ -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: [
{

View File

@@ -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
x-displayName: Cookie Session ID

View File

@@ -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

View File

@@ -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.

View File

@@ -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 <commit-sha>
```
Where `<commit-sha>` 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.

View File

@@ -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"

View File

@@ -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<CodeSample, "source">
/**
* 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<string, unknown> {
const data: Record<string, unknown> = {}
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<string, unknown> = {}
// 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_<randomstring>`.
*/
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

View File

@@ -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,
})
)
}
}

View File

@@ -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<CommonCliOptions, "generateExamples">
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

View File

@@ -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

View File

@@ -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<string> {
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<string> {
// 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.
*

View File

@@ -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

View File

@@ -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

View File

@@ -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<string, OpenApiSchema>
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

View File

@@ -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<CommonCliOptions, "generateExamples">
}
export type GetDocBlockOptions = {
@@ -54,11 +58,20 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
protected checker: ts.TypeChecker
protected defaultSummary = "{summary}"
protected knowledgeBaseFactory: KnowledgeBaseFactory
protected generatorEventManager: GeneratorEventManager
protected options: Pick<CommonCliOptions, "generateExamples">
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<T extends ts.Node = ts.Node> {
*/
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<T extends ts.Node = ts.Node> {
* @param {ts.Type} nodeType - The type of a node.
* @returns {string} The summary comment.
*/
private getTypeDocBlock(
protected getTypeDocBlock(
nodeType: ts.Type,
knowledgeBaseOptions?: Partial<RetrieveOptions>
): string {
@@ -226,7 +243,7 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
* @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<RetrieveOptions>
): string {
@@ -395,26 +412,9 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
}
// 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<T extends ts.Node = ts.Node> {
)
}
getKnowledgeOptions(node: ts.Node): Partial<RetrieveOptions> {
/**
* 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<RetrieveOptions> {
const rawParentName =
"name" in node.parent &&
node.parent.name &&
@@ -498,6 +504,88 @@ class DefaultKindGenerator<T extends ts.Node = ts.Node> {
},
}
}
/**
* 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

View File

@@ -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

View File

@@ -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)
)
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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)
}
/**

View File

@@ -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 \[(?<method>(get|post|delete))\] (?<path>.+)/
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<OasArea, Set<string>> = new Map()
const oasSchemaHelper = new OasSchemaHelper()
const referencedSchemas: Set<string> = new Set()
const allSchemas: Set<string> = new Set()
areas.forEach((area) => {
tags.set(area, new Set<string>())
})
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")
}

View File

@@ -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.`)
}

View File

@@ -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.`)
}

View File

@@ -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.`)
}

View File

@@ -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<Options, "paths">
{ type, ...options }: Omit<Options, "paths"> & 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.`)
}

View File

@@ -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",
},
}

View File

@@ -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 <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("<commitSha>", "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 <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()

View File

@@ -0,0 +1,30 @@
import { OpenAPIV3 } from "openapi-types"
declare type CodeSample = {
lang: string
label: string
source: string
}
export declare type OpenApiOperation = Partial<OpenAPIV3.OperationObject> & {
"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[]
}

View File

@@ -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}`
}

View File

@@ -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")
}

View File

@@ -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
)
}

View File

@@ -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
}

View File

@@ -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...
*

View File

@@ -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

View File

@@ -119,19 +119,19 @@ export type QueryConfig<TEntity extends BaseEntity> = {
*/
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()

View File

@@ -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

View File

@@ -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<OpenAPIObject> {
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",
}
)

View File

@@ -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)