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:
Patrick authored and GitHub committed 2023-03-13 14:21:56 +00:00
1 parent c16f387d1c
commit 7b57695e00
17 files changed
+349 -5

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
---
"@medusajs/openapi-typescript-codegen": minor
---
feat(codegen): x-expanded-relations
+1
View File
@@ -76,6 +76,7 @@
"test:integration:plugins": "turbo run test --no-daemon --filter=integration-tests-plugins", "test:integration:plugins": "turbo run test --no-daemon --filter=integration-tests-plugins",
"test:integration:repositories": "turbo run test --no-daemon --filter=integration-tests-repositories", "test:integration:repositories": "turbo run test --no-daemon --filter=integration-tests-repositories",
"openapi:generate": "yarn ./packages/oas/oas-github-ci run ci", "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:services": "typedoc --options typedoc.services.js",
"generate:js-client": "typedoc --options typedoc.js-client.js", "generate:js-client": "typedoc --options typedoc.js-client.js",
"generate:entities": "typedoc --options typedoc.entities.js", "generate:entities": "typedoc --options typedoc.entities.js",
@@ -154,6 +154,52 @@ export const defaultStoreCartRelations = [
/** /**
* @schema StoreCartsRes * @schema StoreCartsRes
* type: object * 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: * required:
* - cart * - cart
* properties: * properties:
@@ -16,6 +16,12 @@ export default (app) => {
/** /**
* @schema StoreRegionsListRes * @schema StoreRegionsListRes
* type: object * type: object
* x-expanded-relations:
* field: regions
* relations:
* - countries
* - payment_providers
* - fulfillment_providers
* required: * required:
* - regions * - regions
* properties: * properties:
@@ -31,6 +37,12 @@ export type StoreRegionsListRes = {
/** /**
* @schema StoreRegionsRes * @schema StoreRegionsRes
* type: object * type: object
* x-expanded-relations:
* field: region
* relations:
* - countries
* - payment_providers
* - fulfillment_providers
* required: * required:
* - region * - region
* properties: * properties:
+1 -1
View File
@@ -31,7 +31,7 @@
"scripts": { "scripts": {
"prepare": "cross-env NODE_ENV=production yarn run build", "prepare": "cross-env NODE_ENV=production yarn run build",
"build": "tsc --build", "build": "tsc --build",
"cli": "ts-node src/index.ts", "medusa-oas": "ts-node src/index.ts",
"test": "jest src", "test": "jest src",
"test:unit": "jest src" "test:unit": "jest src"
}, },
@@ -21,7 +21,7 @@ const getTmpDirectory = async () => {
} }
const runCLI = async (command: string, options: string[] = []) => { const runCLI = async (command: string, options: string[] = []) => {
const params = ["run", "cli", command, ...options] const params = ["run", "medusa-oas", command, ...options]
try { try {
const { all: logs } = await execa("yarn", params, { const { all: logs } = await execa("yarn", params, {
cwd: basePath, cwd: basePath,
@@ -1,6 +1,14 @@
import type { Enum } from "./Enum" import type { Enum } from "./Enum"
import type { Schema } from "./Schema" import type { Schema } from "./Schema"
export type NestedRelation = {
field: string
nestedRelations: NestedRelation[]
base?: string
isArray?: boolean
hasDepth?: boolean
}
export interface Model extends Schema { export interface Model extends Schema {
name: string name: string
export: export:
@@ -24,4 +32,5 @@ export interface Model extends Schema {
enum: Enum[] enum: Enum[]
enums: Model[] enums: Model[]
properties: 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 { OpenApiExternalDocs } from "./OpenApiExternalDocs"
import type { OpenApiReference } from "./OpenApiReference" import type { OpenApiReference } from "./OpenApiReference"
import type { OpenApiXml } from "./OpenApiXml" import type { OpenApiXml } from "./OpenApiXml"
import { WithExtendedRelationsExtension } from "./Extensions/WithDefaultRelationsExtension"
/** /**
* https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schemaObject * 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 title?: string
multipleOf?: number multipleOf?: number
maximum?: number maximum?: number
@@ -8,6 +8,7 @@ import { OpenApiSchema } from "../interfaces/OpenApiSchema"
import { Dictionary } from "../../../utils/types" import { Dictionary } from "../../../utils/types"
import { OpenApiParameter } from "../interfaces/OpenApiParameter" import { OpenApiParameter } from "../interfaces/OpenApiParameter"
import { listOperations } from "./listOperations" import { listOperations } from "./listOperations"
import { handleExpandedRelations } from "./getModelsExpandedRelations"
export const getModels = (openApi: OpenApi): Model[] => { export const getModels = (openApi: OpenApi): Model[] => {
const models: 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 * Bundle all query parameters in a single typed object
* when x-codegen.queryParams is declared on the operation. * 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}} {{>header}}
import { SetRequired, Merge } from '../core/ModelUtils';
{{#if imports}} {{#if imports}}
{{#each imports}} {{#each imports}}
@@ -8,7 +8,7 @@
{{/if}} {{/if}}
*/ */
{{/ifdef}} {{/ifdef}}
export type {{{name}}} = { export interface {{{name}}} {
{{#each properties}} {{#each properties}}
{{#ifdef description deprecated}} {{#ifdef description deprecated}}
/** /**
@@ -20,9 +20,16 @@ export type {{{name}}} = {
{{/if}} {{/if}}
*/ */
{{/ifdef}} {{/ifdef}}
{{>isReadOnly}}{{{name}}}{{>isRequired}}: {{>type parent=../name}}; {{~#if nestedRelations}}
{{#each nestedRelations}}
{{>isReadOnly}}{{{../name}}}: {{>typeWithRelation}};
{{/each}}
{{else}}
{{>isReadOnly}}{{{name}}}{{>isRequired}}: {{>type parent=../name}};
{{/if}}
{{/each}} {{/each}}
}; };
{{#if enums}} {{#if enums}}
{{#unless @root.useUnionTypes}} {{#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 templateCoreApiRequestOptions from "../templates/core/ApiRequestOptions.hbs"
import templateCoreApiResult from "../templates/core/ApiResult.hbs" import templateCoreApiResult from "../templates/core/ApiResult.hbs"
import templateCoreHookUtils from "../templates/core/HookUtils.hbs" import templateCoreHookUtils from "../templates/core/HookUtils.hbs"
import templateCoreModelUtils from "../templates/core/ModelUtils.hbs"
import axiosGetHeaders from "../templates/core/axios/getHeaders.hbs" import axiosGetHeaders from "../templates/core/axios/getHeaders.hbs"
import axiosGetRequestBody from "../templates/core/axios/getRequestBody.hbs" import axiosGetRequestBody from "../templates/core/axios/getRequestBody.hbs"
import axiosGetResponseBody from "../templates/core/axios/getResponseBody.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 partialTypeIntersection from "../templates/partials/typeIntersection.hbs"
import partialTypeReference from "../templates/partials/typeReference.hbs" import partialTypeReference from "../templates/partials/typeReference.hbs"
import partialTypeUnion from "../templates/partials/typeUnion.hbs" import partialTypeUnion from "../templates/partials/typeUnion.hbs"
import partialTypeWithRelation from "../templates/partials/typeWithRelation.hbs"
import { registerHandlebarHelpers } from "./registerHandlebarHelpers" import { registerHandlebarHelpers } from "./registerHandlebarHelpers"
export interface Templates { export interface Templates {
@@ -111,6 +113,7 @@ export interface Templates {
baseHttpRequest: Handlebars.TemplateDelegate baseHttpRequest: Handlebars.TemplateDelegate
httpRequest: Handlebars.TemplateDelegate httpRequest: Handlebars.TemplateDelegate
hookUtils: Handlebars.TemplateDelegate hookUtils: Handlebars.TemplateDelegate
modelUtils: Handlebars.TemplateDelegate
} }
} }
@@ -151,6 +154,7 @@ export const registerHandlebarTemplates = (root: {
baseHttpRequest: Handlebars.template(templateCoreBaseHttpRequest), baseHttpRequest: Handlebars.template(templateCoreBaseHttpRequest),
httpRequest: Handlebars.template(templateCoreHttpRequest), httpRequest: Handlebars.template(templateCoreHttpRequest),
hookUtils: Handlebars.template(templateCoreHookUtils), hookUtils: Handlebars.template(templateCoreHookUtils),
modelUtils: Handlebars.template(templateCoreModelUtils),
}, },
} }
@@ -242,6 +246,10 @@ export const registerHandlebarTemplates = (root: {
"typeIntersection", "typeIntersection",
Handlebars.template(partialTypeIntersection) Handlebars.template(partialTypeIntersection)
) )
Handlebars.registerPartial(
"typeWithRelation",
Handlebars.template(partialTypeWithRelation)
)
Handlebars.registerPartial("base", Handlebars.template(partialBase)) Handlebars.registerPartial("base", Handlebars.template(partialBase))
// Generic functions used in 'request' file @see src/templates/core/request.hbs for more info // 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) { if (exportModels) {
await rmdir(outputPathModels) await rmdir(outputPathModels)
await mkdir(outputPathModels) await mkdir(outputPathModels)
await mkdir(outputPathCore)
await writeFile(
resolve(outputPathCore, "ModelUtils.ts"),
i(templates.core.modelUtils({}), indent)
)
await writeClientModels( await writeClientModels(
client.models, client.models,
templates, templates,