feat(codegen): x-expanded-relations (#3442)
## What Alter generated types base on `x-expanded-relations` OAS extension declared on schemaObjects. ## Why Often, API endpoints will automatically expand a model relations by default. They can also decorate a model with calculated totals. In order to more accurately represent the API, we wish to alter the generated types based on the expanded relations information. ## How - Follow the relation declaration signature as the backend controllers and the `expand` query param, i.e.: `items.variant.product`. - Introduce a custom `x-expended-relations` OAS extension. - Allow for organizing declared relations to help their maintenance. - Use traversal algorithms in codegen to support deeply nested relationships. - Use [type-fest](https://www.npmjs.com/package/type-fest)'s `Merge` and `SetRequired` to efficiently alter the types while enabling great intellisense for IDEs. Extra scope: * Added convenience yarn script to interact with the `medisa-oas` CLI within the monorepo. ## Test Include in the PR are two implementations of the x-expanded-relations on OAS schema, a simple and a complex one. ### Step 1 * Run `yarn install` * Run `yarn build` * Run `yarn medusa-oas oas --type combined --out-dir ~/tmp/oas` * Run `yarn medusa-oas client --type combined --component types --src-file ~/tmp/oas/combined.osa.json --out-dir ~/tmp/types` * Open `~/tmp/types/models/StoreRegionsRes` * Expect relations to be declared as required ### Step 2 * Open `~/tmp/types/models/StoreCartsRes` * Expect relations to be declared as required * Expect nested relations to have relations as required. ### Step 3 (optional) * Open `~/tmp/types` in an intellisense capable IDE * Within the `index.ts` file, attempt to declare a `const storeRegionRes: StoreRegionRes = {}` * Expect IDE to highlight that `countries` is a required field of `StoreRegionRes`
This commit is contained in:
5
.changeset/tame-forks-marry.md
Normal file
5
.changeset/tame-forks-marry.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@medusajs/openapi-typescript-codegen": minor
|
||||
---
|
||||
|
||||
feat(codegen): x-expanded-relations
|
||||
@@ -76,6 +76,7 @@
|
||||
"test:integration:plugins": "turbo run test --no-daemon --filter=integration-tests-plugins",
|
||||
"test:integration:repositories": "turbo run test --no-daemon --filter=integration-tests-repositories",
|
||||
"openapi:generate": "yarn ./packages/oas/oas-github-ci run ci",
|
||||
"medusa-oas": "yarn ./packages/oas/medusa-oas-cli run medusa-oas",
|
||||
"generate:services": "typedoc --options typedoc.services.js",
|
||||
"generate:js-client": "typedoc --options typedoc.js-client.js",
|
||||
"generate:entities": "typedoc --options typedoc.entities.js",
|
||||
|
||||
@@ -154,6 +154,52 @@ export const defaultStoreCartRelations = [
|
||||
/**
|
||||
* @schema StoreCartsRes
|
||||
* type: object
|
||||
* x-expanded-relations:
|
||||
* field: cart
|
||||
* relations:
|
||||
* - billing_address
|
||||
* - discounts
|
||||
* - discounts.rule
|
||||
* - gift_cards
|
||||
* - items
|
||||
* - items.adjustments
|
||||
* - items.variant
|
||||
* - payment
|
||||
* - payment_sessions
|
||||
* - region
|
||||
* - region.countries
|
||||
* - region.payment_providers
|
||||
* - shipping_address
|
||||
* - shipping_methods
|
||||
* - shipping_methods.shipping_option
|
||||
* implicit:
|
||||
* - items.tax_lines
|
||||
* - items.variant.product
|
||||
* - region.fulfillment_providers
|
||||
* - region.payment_providers
|
||||
* - region.tax_rates
|
||||
* - shipping_methods.shipping_option
|
||||
* - shipping_methods.tax_lines
|
||||
* totals:
|
||||
* - discount_total
|
||||
* - gift_card_tax_total
|
||||
* - gift_card_total
|
||||
* - item_tax_total
|
||||
* - refundable_amount
|
||||
* - refunded_total
|
||||
* - shipping_tax_total
|
||||
* - shipping_total
|
||||
* - subtotal
|
||||
* - tax_total
|
||||
* - total
|
||||
* - items.discount_total
|
||||
* - items.gift_card_total
|
||||
* - items.original_tax_total
|
||||
* - items.original_total
|
||||
* - items.refundable
|
||||
* - items.subtotal
|
||||
* - items.tax_total
|
||||
* - items.total
|
||||
* required:
|
||||
* - cart
|
||||
* properties:
|
||||
|
||||
@@ -16,6 +16,12 @@ export default (app) => {
|
||||
/**
|
||||
* @schema StoreRegionsListRes
|
||||
* type: object
|
||||
* x-expanded-relations:
|
||||
* field: regions
|
||||
* relations:
|
||||
* - countries
|
||||
* - payment_providers
|
||||
* - fulfillment_providers
|
||||
* required:
|
||||
* - regions
|
||||
* properties:
|
||||
@@ -31,6 +37,12 @@ export type StoreRegionsListRes = {
|
||||
/**
|
||||
* @schema StoreRegionsRes
|
||||
* type: object
|
||||
* x-expanded-relations:
|
||||
* field: region
|
||||
* relations:
|
||||
* - countries
|
||||
* - payment_providers
|
||||
* - fulfillment_providers
|
||||
* required:
|
||||
* - region
|
||||
* properties:
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"scripts": {
|
||||
"prepare": "cross-env NODE_ENV=production yarn run build",
|
||||
"build": "tsc --build",
|
||||
"cli": "ts-node src/index.ts",
|
||||
"medusa-oas": "ts-node src/index.ts",
|
||||
"test": "jest src",
|
||||
"test:unit": "jest src"
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ const getTmpDirectory = async () => {
|
||||
}
|
||||
|
||||
const runCLI = async (command: string, options: string[] = []) => {
|
||||
const params = ["run", "cli", command, ...options]
|
||||
const params = ["run", "medusa-oas", command, ...options]
|
||||
try {
|
||||
const { all: logs } = await execa("yarn", params, {
|
||||
cwd: basePath,
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { Enum } from "./Enum"
|
||||
import type { Schema } from "./Schema"
|
||||
|
||||
export type NestedRelation = {
|
||||
field: string
|
||||
nestedRelations: NestedRelation[]
|
||||
base?: string
|
||||
isArray?: boolean
|
||||
hasDepth?: boolean
|
||||
}
|
||||
|
||||
export interface Model extends Schema {
|
||||
name: string
|
||||
export:
|
||||
@@ -24,4 +32,5 @@ export interface Model extends Schema {
|
||||
enum: Enum[]
|
||||
enums: Model[]
|
||||
properties: Model[]
|
||||
nestedRelations?: NestedRelation[]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface WithExtendedRelationsExtension {
|
||||
"x-expanded-relations"?: {
|
||||
field: string
|
||||
relations?: string[]
|
||||
totals?: string[]
|
||||
implicit?: string[]
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,15 @@ import type { OpenApiDiscriminator } from "./OpenApiDiscriminator"
|
||||
import type { OpenApiExternalDocs } from "./OpenApiExternalDocs"
|
||||
import type { OpenApiReference } from "./OpenApiReference"
|
||||
import type { OpenApiXml } from "./OpenApiXml"
|
||||
import { WithExtendedRelationsExtension } from "./Extensions/WithDefaultRelationsExtension"
|
||||
|
||||
/**
|
||||
* https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject
|
||||
*/
|
||||
export interface OpenApiSchema extends OpenApiReference, WithEnumExtension {
|
||||
export interface OpenApiSchema
|
||||
extends OpenApiReference,
|
||||
WithEnumExtension,
|
||||
WithExtendedRelationsExtension {
|
||||
title?: string
|
||||
multipleOf?: number
|
||||
maximum?: number
|
||||
|
||||
@@ -8,6 +8,7 @@ import { OpenApiSchema } from "../interfaces/OpenApiSchema"
|
||||
import { Dictionary } from "../../../utils/types"
|
||||
import { OpenApiParameter } from "../interfaces/OpenApiParameter"
|
||||
import { listOperations } from "./listOperations"
|
||||
import { handleExpandedRelations } from "./getModelsExpandedRelations"
|
||||
|
||||
export const getModels = (openApi: OpenApi): Model[] => {
|
||||
const models: Model[] = []
|
||||
@@ -27,6 +28,10 @@ export const getModels = (openApi: OpenApi): Model[] => {
|
||||
}
|
||||
}
|
||||
|
||||
for (const model of models) {
|
||||
handleExpandedRelations(model, models)
|
||||
}
|
||||
|
||||
/**
|
||||
* Bundle all query parameters in a single typed object
|
||||
* when x-codegen.queryParams is declared on the operation.
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Model, NestedRelation } from "../../../client/interfaces/Model"
|
||||
|
||||
export const handleExpandedRelations = (model: Model, allModels: Model[]) => {
|
||||
const xExpandedRelation = model.spec["x-expanded-relations"]
|
||||
if (!xExpandedRelation) {
|
||||
return
|
||||
}
|
||||
const field = xExpandedRelation.field
|
||||
const relations = xExpandedRelation.relations ?? []
|
||||
const totals = xExpandedRelation.totals ?? []
|
||||
const implicit = xExpandedRelation.implicit ?? []
|
||||
|
||||
const nestedRelation: NestedRelation = {
|
||||
field,
|
||||
nestedRelations: [],
|
||||
}
|
||||
|
||||
for (const relation of [...relations, ...totals, ...implicit]) {
|
||||
const splitRelation = relation.split(".")
|
||||
walkSplitRelations(nestedRelation, splitRelation, 0)
|
||||
}
|
||||
|
||||
walkNestedRelations(allModels, model, model, nestedRelation)
|
||||
model.imports = [...new Set(model.imports)]
|
||||
|
||||
const prop = getPropertyByName(nestedRelation.field, model)
|
||||
if (prop) {
|
||||
prop.nestedRelations = [nestedRelation]
|
||||
}
|
||||
}
|
||||
|
||||
const walkSplitRelations = (
|
||||
parentNestedRelation: NestedRelation,
|
||||
splitRelation: string[],
|
||||
depthIndex: number
|
||||
) => {
|
||||
const field = splitRelation[depthIndex]
|
||||
let nestedRelation: NestedRelation | undefined =
|
||||
parentNestedRelation.nestedRelations.find(
|
||||
(nestedRelation) => nestedRelation.field === field
|
||||
)
|
||||
if (!nestedRelation) {
|
||||
nestedRelation = {
|
||||
field,
|
||||
nestedRelations: [],
|
||||
}
|
||||
parentNestedRelation.nestedRelations.push(nestedRelation)
|
||||
}
|
||||
depthIndex++
|
||||
if (depthIndex < splitRelation.length) {
|
||||
walkSplitRelations(nestedRelation, splitRelation, depthIndex)
|
||||
}
|
||||
}
|
||||
|
||||
const walkNestedRelations = (
|
||||
allModels: Model[],
|
||||
rootModel: Model,
|
||||
model: Model,
|
||||
nestedRelation: NestedRelation,
|
||||
parentNestedRelation?: NestedRelation
|
||||
) => {
|
||||
const prop =
|
||||
model.export === "all-of"
|
||||
? findPropInAllOf(nestedRelation.field, model, allModels)
|
||||
: getPropertyByName(nestedRelation.field, model)
|
||||
if (!prop) {
|
||||
return
|
||||
}
|
||||
if (!["reference", "array"].includes(prop.export)) {
|
||||
return
|
||||
}
|
||||
|
||||
nestedRelation.base = prop.type
|
||||
nestedRelation.isArray = prop.export === "array"
|
||||
|
||||
for (const childNestedRelation of nestedRelation.nestedRelations) {
|
||||
const childModel = getModelByName(prop.type, allModels)
|
||||
if (!childModel) {
|
||||
return
|
||||
}
|
||||
rootModel.imports.push(prop.type)
|
||||
if (parentNestedRelation) {
|
||||
parentNestedRelation.hasDepth = true
|
||||
}
|
||||
walkNestedRelations(
|
||||
allModels,
|
||||
rootModel,
|
||||
childModel,
|
||||
childNestedRelation,
|
||||
nestedRelation
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const findPropInAllOf = (
|
||||
fieldName: string,
|
||||
model: Model,
|
||||
allModels: Model[]
|
||||
) => {
|
||||
for (const property of model.properties) {
|
||||
switch (property.export) {
|
||||
case "interface":
|
||||
return getPropertyByName(fieldName, model)
|
||||
case "reference":
|
||||
const tmpModel = getModelByName(property.type, allModels)
|
||||
if (tmpModel) {
|
||||
return getPropertyByName(fieldName, tmpModel)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getModelByName(name: string, models: Model[]): Model | void {
|
||||
for (const model of models) {
|
||||
if (model.name === name) {
|
||||
return model
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getPropertyByName(name: string, model: Model): Model | void {
|
||||
for (const property of model.properties) {
|
||||
if (property.name === name) {
|
||||
return property
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
{{>header}}
|
||||
|
||||
/**
|
||||
* Typing utilities from https://github.com/sindresorhus/type-fest
|
||||
*/
|
||||
|
||||
/**
|
||||
* private methods for exportable dependencies
|
||||
*/
|
||||
// https://github.com/sindresorhus/type-fest/blob/main/source/except.d.ts
|
||||
type Filter<KeyType, ExcludeType> = IsEqual<KeyType, ExcludeType> extends true ? never : (KeyType extends ExcludeType ? never : KeyType);
|
||||
|
||||
// https://github.com/sindresorhus/type-fest/blob/main/source/enforce-optional.d.ts
|
||||
type RequiredFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
|
||||
? Type[Key] extends undefined
|
||||
? Key
|
||||
: never
|
||||
: Key;
|
||||
|
||||
type OptionalFilter<Type, Key extends keyof Type> = undefined extends Type[Key]
|
||||
? Type[Key] extends undefined
|
||||
? never
|
||||
: Key
|
||||
: never;
|
||||
|
||||
// https://github.com/sindresorhus/type-fest/blob/main/source/merge.d.ts
|
||||
type SimpleMerge<Destination, Source> = {
|
||||
[Key in keyof Destination | keyof Source]: Key extends keyof Source
|
||||
? Source[Key]
|
||||
: Key extends keyof Destination
|
||||
? Destination[Key]
|
||||
: never;
|
||||
};
|
||||
|
||||
/**
|
||||
* optional exportable dependencies
|
||||
*/
|
||||
export type Simplify<T> = {[KeyType in keyof T]: T[KeyType]} & {};
|
||||
|
||||
export type IsEqual<A, B> =
|
||||
(<G>() => G extends A ? 1 : 2) extends
|
||||
(<G>() => G extends B ? 1 : 2)
|
||||
? true
|
||||
: false;
|
||||
|
||||
export type Except<ObjectType, KeysType extends keyof ObjectType> = {
|
||||
[KeyType in keyof ObjectType as Filter<KeyType, KeysType>]: ObjectType[KeyType];
|
||||
};
|
||||
|
||||
export type OmitIndexSignature<ObjectType> = {
|
||||
[KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
|
||||
? never
|
||||
: KeyType]: ObjectType[KeyType];
|
||||
};
|
||||
|
||||
export type PickIndexSignature<ObjectType> = {
|
||||
[KeyType in keyof ObjectType as {} extends Record<KeyType, unknown>
|
||||
? KeyType
|
||||
: never]: ObjectType[KeyType];
|
||||
};
|
||||
|
||||
export type EnforceOptional<ObjectType> = Simplify<{
|
||||
[Key in keyof ObjectType as RequiredFilter<ObjectType, Key>]: ObjectType[Key]
|
||||
} & {
|
||||
[Key in keyof ObjectType as OptionalFilter<ObjectType, Key>]?: Exclude<ObjectType[Key], undefined>
|
||||
}>;
|
||||
|
||||
/**
|
||||
* SetRequired
|
||||
*/
|
||||
export type SetRequired<BaseType, Keys extends keyof BaseType> =
|
||||
Simplify<
|
||||
// Pick just the keys that are optional from the base type.
|
||||
Except<BaseType, Keys> &
|
||||
// Pick the keys that should be required from the base type and make them required.
|
||||
Required<Pick<BaseType, Keys>>
|
||||
>;
|
||||
|
||||
/**
|
||||
* SetNonNullable
|
||||
*/
|
||||
export type SetNonNullable<BaseType, Keys extends keyof BaseType = keyof BaseType> = {
|
||||
[Key in keyof BaseType]: Key extends Keys
|
||||
? NonNullable<BaseType[Key]>
|
||||
: BaseType[Key];
|
||||
};
|
||||
|
||||
/**
|
||||
* Merge
|
||||
*/
|
||||
export type Merge<Destination, Source> = EnforceOptional<
|
||||
SimpleMerge<PickIndexSignature<Destination>, PickIndexSignature<Source>>
|
||||
& SimpleMerge<OmitIndexSignature<Destination>, OmitIndexSignature<Source>>
|
||||
>;
|
||||
@@ -1,5 +1,6 @@
|
||||
{{>header}}
|
||||
|
||||
import { SetRequired, Merge } from '../core/ModelUtils';
|
||||
{{#if imports}}
|
||||
|
||||
{{#each imports}}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
{{/if}}
|
||||
*/
|
||||
{{/ifdef}}
|
||||
export type {{{name}}} = {
|
||||
export interface {{{name}}} {
|
||||
{{#each properties}}
|
||||
{{#ifdef description deprecated}}
|
||||
/**
|
||||
@@ -20,9 +20,16 @@ export type {{{name}}} = {
|
||||
{{/if}}
|
||||
*/
|
||||
{{/ifdef}}
|
||||
{{>isReadOnly}}{{{name}}}{{>isRequired}}: {{>type parent=../name}};
|
||||
{{~#if nestedRelations}}
|
||||
{{#each nestedRelations}}
|
||||
{{>isReadOnly}}{{{../name}}}: {{>typeWithRelation}};
|
||||
{{/each}}
|
||||
{{else}}
|
||||
{{>isReadOnly}}{{{name}}}{{>isRequired}}: {{>type parent=../name}};
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
};
|
||||
|
||||
{{#if enums}}
|
||||
{{#unless @root.useUnionTypes}}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
{{~#if isArray}}Array<{{/if~}}
|
||||
{{~#if hasDepth}}Merge<{{/if~}}
|
||||
SetRequired<{{>base}}, {{#each nestedRelations}}'{{{field}}}'{{#unless @last}} | {{/unless}}{{/each}}>{{#if hasDepth}}, {
|
||||
{{/if}}
|
||||
{{~#each nestedRelations~}}
|
||||
{{#if nestedRelations~}}
|
||||
{{{field}}}: {{>typeWithRelation}},
|
||||
{{/if~}}
|
||||
{{~/each~}}
|
||||
{{~#if hasDepth~}} }>{{/if~}}
|
||||
{{~#if isArray}}>{{/if~}}
|
||||
@@ -7,6 +7,7 @@ import templateCoreApiError from "../templates/core/ApiError.hbs"
|
||||
import templateCoreApiRequestOptions from "../templates/core/ApiRequestOptions.hbs"
|
||||
import templateCoreApiResult from "../templates/core/ApiResult.hbs"
|
||||
import templateCoreHookUtils from "../templates/core/HookUtils.hbs"
|
||||
import templateCoreModelUtils from "../templates/core/ModelUtils.hbs"
|
||||
import axiosGetHeaders from "../templates/core/axios/getHeaders.hbs"
|
||||
import axiosGetRequestBody from "../templates/core/axios/getRequestBody.hbs"
|
||||
import axiosGetResponseBody from "../templates/core/axios/getResponseBody.hbs"
|
||||
@@ -84,6 +85,7 @@ import partialTypeInterface from "../templates/partials/typeInterface.hbs"
|
||||
import partialTypeIntersection from "../templates/partials/typeIntersection.hbs"
|
||||
import partialTypeReference from "../templates/partials/typeReference.hbs"
|
||||
import partialTypeUnion from "../templates/partials/typeUnion.hbs"
|
||||
import partialTypeWithRelation from "../templates/partials/typeWithRelation.hbs"
|
||||
import { registerHandlebarHelpers } from "./registerHandlebarHelpers"
|
||||
|
||||
export interface Templates {
|
||||
@@ -111,6 +113,7 @@ export interface Templates {
|
||||
baseHttpRequest: Handlebars.TemplateDelegate
|
||||
httpRequest: Handlebars.TemplateDelegate
|
||||
hookUtils: Handlebars.TemplateDelegate
|
||||
modelUtils: Handlebars.TemplateDelegate
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +154,7 @@ export const registerHandlebarTemplates = (root: {
|
||||
baseHttpRequest: Handlebars.template(templateCoreBaseHttpRequest),
|
||||
httpRequest: Handlebars.template(templateCoreHttpRequest),
|
||||
hookUtils: Handlebars.template(templateCoreHookUtils),
|
||||
modelUtils: Handlebars.template(templateCoreModelUtils),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -242,6 +246,10 @@ export const registerHandlebarTemplates = (root: {
|
||||
"typeIntersection",
|
||||
Handlebars.template(partialTypeIntersection)
|
||||
)
|
||||
Handlebars.registerPartial(
|
||||
"typeWithRelation",
|
||||
Handlebars.template(partialTypeWithRelation)
|
||||
)
|
||||
Handlebars.registerPartial("base", Handlebars.template(partialBase))
|
||||
|
||||
// Generic functions used in 'request' file @see src/templates/core/request.hbs for more info
|
||||
|
||||
@@ -171,6 +171,11 @@ export const writeClient = async (
|
||||
if (exportModels) {
|
||||
await rmdir(outputPathModels)
|
||||
await mkdir(outputPathModels)
|
||||
await mkdir(outputPathCore)
|
||||
await writeFile(
|
||||
resolve(outputPathCore, "ModelUtils.ts"),
|
||||
i(templates.core.modelUtils({}), indent)
|
||||
)
|
||||
await writeClientModels(
|
||||
client.models,
|
||||
templates,
|
||||
|
||||
Reference in New Issue
Block a user