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
2023-03-13 10:21:56 -04:00
committed by GitHub
parent c16f387d1c
commit 7b57695e00
17 changed files with 349 additions and 5 deletions

View File

@@ -0,0 +1,5 @@
---
"@medusajs/openapi-typescript-codegen": minor
---
feat(codegen): x-expanded-relations

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
export interface WithExtendedRelationsExtension {
"x-expanded-relations"?: {
field: string
relations?: string[]
totals?: string[]
implicit?: string[]
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
{{>header}}
import { SetRequired, Merge } from '../core/ModelUtils';
{{#if imports}}
{{#each imports}}

View File

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

View File

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

View File

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

View File

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