Skip to content

Commit

Permalink
feat(opapi): allow exporting arbitrary schemas (#364)
Browse files Browse the repository at this point in the history
  • Loading branch information
franklevasseur authored Jul 24, 2024
1 parent 183dcd1 commit 9b43dcd
Show file tree
Hide file tree
Showing 8 changed files with 631 additions and 69 deletions.
3 changes: 2 additions & 1 deletion opapi/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bpinternal/opapi",
"version": "0.10.19",
"version": "0.10.20",
"description": "",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
Expand Down Expand Up @@ -45,6 +45,7 @@
"decompress": "4.2.1",
"execa": "8.0.1",
"json-schema-to-typescript": "13.1.2",
"json-schema-to-zod": "1.1.1",
"lodash": "^4.17.21",
"openapi-typescript": "6.7.6",
"openapi3-ts": "2.0.2",
Expand Down
293 changes: 293 additions & 0 deletions opapi/pnpm-lock.yaml

Large diffs are not rendered by default.

150 changes: 111 additions & 39 deletions opapi/src/handler-generator/export-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,22 @@ import * as jsonschema from '../jsonschema'
import * as utils from './utils'
import pathlib from 'path'
import fs from 'fs/promises'
import { jsonSchemaToZod } from 'json-schema-to-zod'
import { OpenApiZodAny } from '@anatine/zod-openapi'

type jsonSchemaToZodInput = Parameters<typeof jsonSchemaToZod>[0]
type jsonSchemaToTsInput = Parameters<typeof compile>[0]
type Module = { name: string; filename: string }
type ExportSchemasOptions = {
includeJsonSchemas: boolean
includeZodSchemas: boolean
includeTypes: boolean
}

const toTs = async (originalSchema: JSONSchema7, name: string): Promise<string> => {
const jsonSchemaToTs = async (originalSchema: JSONSchema7, name: string): Promise<string> => {
let { title, ...schema } = originalSchema
schema = jsonschema.setDefaultAdditionalProperties(schema, false)

type jsonSchemaToTsInput = Parameters<typeof compile>[0]
const typeCode = await compile(schema as jsonSchemaToTsInput, name, {
unknownAny: false,
bannerComment: '',
Expand All @@ -22,48 +30,112 @@ const toTs = async (originalSchema: JSONSchema7, name: string): Promise<string>
return `${typeCode}\n`
}

export const exportSchemas = (schemas: Record<string, JSONSchema7>) => async (outDir: string) => {
await fs.mkdir(outDir, { recursive: true })
const zodToJsonSchema = (zodSchema: OpenApiZodAny): JSONSchema7 => {
let jsonSchema = jsonschema.generateSchemaFromZod(zodSchema, { allowUnions: true }) as JSONSchema7
jsonSchema = jsonschema.replaceNullableWithUnion(jsonSchema)
jsonSchema = jsonschema.replaceOneOfWithAnyOf(jsonSchema)
return jsonSchema
}

const DEFAULT_OPTIONS: ExportSchemasOptions = {
includeJsonSchemas: true,
includeZodSchemas: true,
includeTypes: true,
}

/**
* export any record of json schema to:
* - json schemas
* - zod schemas
* - typescript types
*
* allows fully separating build time schemas from the ones used at runtime
*/
export const exportJsonSchemas =
(schemas: Record<string, JSONSchema7>) =>
async (outDir: string, opts: Partial<ExportSchemasOptions> = {}) => {
const options = { ...DEFAULT_OPTIONS, ...opts }
await fs.mkdir(outDir, { recursive: true })

const jsonFiles: Module[] = []
const typeFiles: Module[] = []
const jsonFiles: Module[] = []
const zodFiles: Module[] = []
const typeFiles: Module[] = []

for (const [name, schema] of Object.entries(schemas)) {
const jsonSchema = jsonschema.replaceNullableWithUnion(schema)
for (const [name, schema] of Object.entries(schemas)) {
const jsonSchema = jsonschema.replaceNullableWithUnion(schema)

// json file
const jsonFileName = `${name}.j`
const jsonCode = [
"import type { JSONSchema7 } from 'json-schema'",
`const schema: JSONSchema7 = ${JSON.stringify(jsonSchema, null, 2)}`,
`export default schema`,
// json file
if (options.includeJsonSchemas) {
const jsonFileName = `${name}.j`
const jsonCode = [
"import type { JSONSchema7 } from 'json-schema'",
`const schema: JSONSchema7 = ${JSON.stringify(jsonSchema, null, 2)}`,
`export default schema`,
].join('\n')
const jsonFilePath = pathlib.join(outDir, `${jsonFileName}.ts`)
await fs.writeFile(jsonFilePath, jsonCode)
jsonFiles.push({ name, filename: jsonFileName })
}

// zod file
if (options.includeZodSchemas) {
const zodFileName = `${name}.z`
const zodCode = jsonSchemaToZod(jsonSchema as jsonSchemaToZodInput).replace(/\.catchall\(z\.never\(\)\)/g, '')
const zodFilePath = pathlib.join(outDir, `${zodFileName}.ts`)
await fs.writeFile(zodFilePath, zodCode)
zodFiles.push({ name, filename: zodFileName })
}

// type file
if (options.includeTypes) {
const typeFileName = `${name}.t`
const typeCode = await jsonSchemaToTs(jsonSchema, name)
const typeFilePath = pathlib.join(outDir, `${typeFileName}.ts`)
await fs.writeFile(typeFilePath, typeCode)
typeFiles.push({ name, filename: typeFileName })
}
}

// index file
const indexCode = [
...jsonFiles.map(({ name, filename }) => `import json_${name} from './${filename}'`),
...zodFiles.map(({ name, filename }) => `import zod_${name} from './${filename}'`),
...typeFiles.map(({ name, filename }) => `import type { ${utils.pascalCase(name)} } from './${filename}'`),
'',
`export const json = {`,
...jsonFiles.map(({ name }) => ` ${name}: json_${name},`),
`}`,
'',
`export const zod = {`,
...zodFiles.map(({ name }) => ` ${name}: zod_${name},`),
`}`,
'',
`export type Types = {`,
...typeFiles.map(({ name }) => ` ${name}: ${utils.pascalCase(name)}`),
`}`,
].join('\n')
const jsonFilePath = pathlib.join(outDir, `${jsonFileName}.ts`)
await fs.writeFile(jsonFilePath, jsonCode)
jsonFiles.push({ name, filename: jsonFileName })

// type file
const typeFileName = `${name}.t`
const typeCode = await toTs(jsonSchema, name)
const typeFilePath = pathlib.join(outDir, `${typeFileName}.ts`)
await fs.writeFile(typeFilePath, typeCode)
typeFiles.push({ name, filename: typeFileName })
const indexPath = pathlib.join(outDir, 'index.ts')
await fs.writeFile(indexPath, indexCode)
}

// index file
const indexCode = [
...jsonFiles.map(({ name, filename }) => `import json_${name} from './${filename}'`),
...typeFiles.map(({ name, filename }) => `import type { ${utils.pascalCase(name)} } from './${filename}'`),
'',
`export const json = {`,
...jsonFiles.map(({ name }) => ` ${name}: json_${name},`),
`}`,
'',
`export type Types = {`,
...typeFiles.map(({ name }) => ` ${name}: ${utils.pascalCase(name)}`),
`}`,
].join('\n')

const indexPath = pathlib.join(outDir, 'index.ts')
await fs.writeFile(indexPath, indexCode)
/**
* export any record of zod schema to:
* - json schemas
* - zod schemas
* - typescript types
*
* allows fully separating build time schemas from the ones used at runtime
*/
export const exportZodSchemas = (schemas: Record<string, OpenApiZodAny>) => {
const jsonSchemas = Object.entries(schemas).reduce(
(acc, [name, zodSchema]) => {
return {
...acc,
[name]: zodToJsonSchema(zodSchema),
}
},
{} as Record<string, JSONSchema7>,
)
return exportJsonSchemas(jsonSchemas)
}
4 changes: 2 additions & 2 deletions opapi/src/handler-generator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { toRequestSchema, toResponseSchema } from './map-operation'
import { exportErrors } from './export-errors'
import { exportTypings } from './export-typings'
import { exportRouteTree } from './export-tree'
import { exportSchemas } from './export-schemas'
import { exportJsonSchemas } from './export-schemas'
import { JSONSchema7 } from 'json-schema'
import { exportHandler } from './export-handler'
import { generateOpenapi } from '../generator'
Expand All @@ -15,7 +15,7 @@ type JsonSchemaMap = Record<string, JSONSchema7>
type ExportableSchema = { exportSchemas: (outDir: string) => Promise<void> }

const toExportableSchema = (schemas: JsonSchemaMap): ExportableSchema => ({
exportSchemas: (outDir: string) => exportSchemas(schemas)(outDir),
exportSchemas: (outDir: string) => exportJsonSchemas(schemas)(outDir, { includeZodSchemas: false }),
})

export const generateHandler = async <Schema extends string, Param extends string, Section extends string>(
Expand Down
50 changes: 49 additions & 1 deletion opapi/src/jsonschema.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { JSONSchema7 } from 'json-schema'
import { test, expect } from 'vitest'
import { JsonSchema, NullableJsonSchema, replaceNullableWithUnion, setDefaultAdditionalProperties } from './jsonschema'
import {
JsonSchema,
NullableJsonSchema,
replaceNullableWithUnion,
replaceOneOfWithAnyOf,
setDefaultAdditionalProperties,
} from './jsonschema'
import { jsonSchemaBuilder, JsonSchemaBuilder } from './handler-generator/utils'
import _ from 'lodash'

Expand Down Expand Up @@ -176,3 +182,45 @@ test('setDefaultAdditionalProperties with real example', () => {
const actual = setDefaultAdditionalProperties(input, false)
expect(actual).toEqual(expected)
})

test('replaceOneOfWithAnyOf', () => {
const input: JsonSchema = {
type: 'object',
properties: {
id: {
oneOf: [
{
type: 'string',
},
{
type: 'number',
},
],
},
},
required: ['id'],
additionalProperties: false,
}

const actual = replaceOneOfWithAnyOf(input)

const expected: JsonSchema = {
type: 'object',
properties: {
id: {
anyOf: [
{
type: 'string',
},
{
type: 'number',
},
],
},
},
required: ['id'],
additionalProperties: false,
}

expect(actual).toEqual(expected)
})
Loading

0 comments on commit 9b43dcd

Please sign in to comment.