Skip to content

Commit

Permalink
feat(codegen): x-expanded-relations (#3442)
Browse files Browse the repository at this point in the history
## 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`
  • Loading branch information
patrick-medusajs authored Mar 13, 2023
1 parent c16f387 commit 7b57695
Show file tree
Hide file tree
Showing 17 changed files with 349 additions and 5 deletions.
5 changes: 5 additions & 0 deletions .changeset/tame-forks-marry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@medusajs/openapi-typescript-codegen": minor
---

feat(codegen): x-expanded-relations
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
46 changes: 46 additions & 0 deletions packages/medusa/src/api/routes/store/carts/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
12 changes: 12 additions & 0 deletions packages/medusa/src/api/routes/store/regions/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion packages/oas/medusa-oas-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -24,4 +32,5 @@ export interface Model extends Schema {
enum: Enum[]
enums: Model[]
properties: Model[]
nestedRelations?: NestedRelation[]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export interface WithExtendedRelationsExtension {
"x-expanded-relations"?: {
field: string
relations?: string[]
totals?: string[]
implicit?: string[]
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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:/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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand All @@ -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.
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading

0 comments on commit 7b57695

Please sign in to comment.