diff --git a/.changeset/smooth-buckets-taste.md b/.changeset/smooth-buckets-taste.md new file mode 100644 index 0000000..eed8c2e --- /dev/null +++ b/.changeset/smooth-buckets-taste.md @@ -0,0 +1,5 @@ +--- +'openapi-ts-json-schema': minor +--- + +Add `fastifyTypeProviderPlugin` plugin diff --git a/README.md b/README.md index baddc42..69410e2 100644 --- a/README.md +++ b/README.md @@ -99,11 +99,18 @@ Beside generating the expected schema files under `outputPath`, `openapiToTsJson } ``` +## Plugins + +`metaData` output can be used to dynamically generate extra custom output based on the generated schemas. + +`openapi-ts-json-schema` currently ships with one plugin specifically designed to better integrate with [Fastify](https://fastify.dev/). + +Read more about plugins in the [dedicated readme](./docs/plugins.md). + ## Todo - Consider merging "operation" and "path" parameters definition - Consider removing required `definitionPathsToGenerateFrom` option in favour of exporting the whole OpenAPI definitions based on the structure defined in specs -- Explore how to preserve `$ref` values and rely on [`json-schema-to-ts` references generic to](https://www.npmjs.com/package/json-schema-to-ts#references) infer types [ci-badge]: https://github.com/toomuchdesign/openapi-ts-json-schema/actions/workflows/ci.yml/badge.svg [ci]: https://github.com/toomuchdesign/openapi-ts-json-schema/actions/workflows/ci.yml diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..7f12184 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,75 @@ +# Plugins + +## Fastify type provider plugin + +This plugin generate the necessary connective tissue to optimally integrate `openapi-ts-json-schema` output with Fastify's [`json-schema-to-ts` type provider](https://github.com/fastify/fastify-type-provider-json-schema-to-ts) preserving JSON schemas `$ref`s. + +The plugin generates a `fastifyTypeProvider.ts` file under `outputPath` exposing: + +- `referenceSchemas`: an array containing all the `$ref` schemas found with relevant `$id` property ready to be registered with [`fastify.addSchema`](https://fastify.dev/docs/latest/Reference/Server/#addschema) +- `References` TS type specifically built to enable `json-schema-to-ts` to resolve `$ref` schema types + +Generate TypeScript JSON schemas: + +```ts +import { + openapiToTsJsonSchema, + fastifyTypeProviderPlugin, +} from 'openapi-ts-json-schema'; + +const { outputPath, metaData } = await openapiToTsJsonSchema({ + openApiSchema: path.resolve(fixtures, 'path/to/open-api-spec.yaml'), + outputPath: 'path/to/generated/schemas', + definitionPathsToGenerateFrom: ['components.schemas', 'paths'], + refHandling: 'keep', +}); + +await fastifyTypeProviderPlugin({ outputPath, metaData }); +``` + +Setup `Fastify` and `json-schema-to-ts` type provider: + +```ts +import fastify from 'fastify'; +import { JsonSchemaToTsProvider } from '@fastify/type-provider-json-schema-to-ts'; +import { + References, + referenceSchemas, +} from 'path/to/generated/schemas/fastifyTypeProvider'; + +// Enable @fastify/type-provider-json-schema-to-ts to resolve all found `$ref` schema types +const server = + fastify().withTypeProvider< + JsonSchemaToTsProvider> + >(); + +/** + * Register `$ref` schemas individually so that they `$ref`s get resolved runtime. + * This also enables @fastify.swagger to re-expose the schemas as shared components. + */ +referenceSchemas.forEach((schema) => { + fastify.addSchema(schema); +}); + +// Reference the shared schema like the following +fastify.get( + '/profile', + { + schema: { + body: { + type: 'object', + properties: { + user: { + $ref: '#/components/schemas/User', + }, + }, + required: ['user'], + }, + } as const, + }, + (req) => { + // givenName and familyName will be correctly typed as strings! + const { givenName, familyName } = req.body.user; + }, +); +``` diff --git a/src/index.ts b/src/index.ts index 3fd60dd..285b071 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,2 @@ export { openapiToTsJsonSchema } from './openapiToTsJsonSchema'; +export { default as fastifyTypeProviderPlugin } from './plugins/fastifyTypeProviderPlugin'; diff --git a/src/plugins/generateRefTypesAsArrayPlugin.ts b/src/plugins/fastifyTypeProviderPlugin.ts similarity index 63% rename from src/plugins/generateRefTypesAsArrayPlugin.ts rename to src/plugins/fastifyTypeProviderPlugin.ts index 6622684..f364ccc 100644 --- a/src/plugins/generateRefTypesAsArrayPlugin.ts +++ b/src/plugins/fastifyTypeProviderPlugin.ts @@ -1,20 +1,17 @@ import { makeRelativePath, formatTypeScript, saveFile } from '../utils'; import type { Plugin, SchemaMetaData } from '../types'; -const FILE_NAME = 'refTypesAsArray.ts'; +const FILE_NAME = 'fastifyTypeProvider.ts'; -const generateRefTypesAsArrayPlugin: Plugin = async ({ - outputPath, - metaData, -}) => { - const refs: SchemaMetaData[] = []; +const fastifyTypeProviderPlugin: Plugin = async ({ outputPath, metaData }) => { + const refSchemaMetaData: SchemaMetaData[] = []; metaData.schemas.forEach((schema) => { if (schema.isRef) { - refs.push(schema); + refSchemaMetaData.push(schema); } }); - const schemas = refs.map( + const schemas = refSchemaMetaData.map( ({ schemaAbsoluteImportPath, schemaUniqueName, schemaId }) => { return { importPath: makeRelativePath({ @@ -39,14 +36,20 @@ const generateRefTypesAsArrayPlugin: Plugin = async ({ output += `\n const ${schema.schemaUniqueName}WithId = {...${schema.schemaUniqueName}, $id: "${schema.schemaId}"} as const;`; }); + // Generate a TS tuple type containing the types of all $ref schema found output += `\n\n - type RefTypes = [ + export type References = [ ${schemas .map((schema) => `typeof ${schema.schemaUniqueName}WithId`) .join(',')} ];`; - output += '\n\nexport default RefTypes'; + // Generate an array af all $ref schema + // @TODO make selected schemas configurable + output += `\n\n + export const referenceSchemas = [ + ${schemas.map((schema) => `${schema.schemaUniqueName}WithId`).join(',')} + ];`; const formattedOutput = await formatTypeScript(output); await saveFile({ @@ -55,4 +58,4 @@ const generateRefTypesAsArrayPlugin: Plugin = async ({ }); }; -export default generateRefTypesAsArrayPlugin; +export default fastifyTypeProviderPlugin; diff --git a/test/plugins/generateRefTypesAsArrayPlugin.test.ts b/test/plugins/fastifyTypeProviderPlugin.test.ts similarity index 51% rename from test/plugins/generateRefTypesAsArrayPlugin.test.ts rename to test/plugins/fastifyTypeProviderPlugin.test.ts index bf9f82b..16e6893 100644 --- a/test/plugins/generateRefTypesAsArrayPlugin.test.ts +++ b/test/plugins/fastifyTypeProviderPlugin.test.ts @@ -2,32 +2,36 @@ import path from 'path'; import fs from 'fs/promises'; import { describe, it, expect } from 'vitest'; import { openapiToTsJsonSchema } from '../../src'; -import generateRefTypesAsArrayPlugin from '../../src/plugins/generateRefTypesAsArrayPlugin'; +import { fastifyTypeProviderPlugin } from '../../src'; import { importFresh } from '../test-utils'; import { formatTypeScript } from '../../src/utils'; const fixtures = path.resolve(__dirname, '../fixtures'); -describe('generateRefTypesAsArrayPlugin plugin', async () => { +describe('fastifyTypeProviderPlugin plugin', () => { it('generates expected file', async () => { const { outputPath, metaData } = await openapiToTsJsonSchema({ openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-fastifyTypeProviderPlugin', + ), definitionPathsToGenerateFrom: ['components.months', 'paths'], refHandling: 'keep', silent: true, }); - await generateRefTypesAsArrayPlugin({ outputPath, metaData }); + await fastifyTypeProviderPlugin({ outputPath, metaData }); - const actual = await fs.readFile( - path.resolve(outputPath, 'refTypesAsArray.ts'), + const actualAsText = await fs.readFile( + path.resolve(outputPath, 'fastifyTypeProvider.ts'), { encoding: 'utf8', }, ); // @TODO find a better way to assert against generated types - const expected = await formatTypeScript(` + const expectedAsText = await formatTypeScript(` import componentsSchemasAnswer from "./components/schemas/Answer"; import componentsMonthsJanuary from "./components/months/January"; import componentsMonthsFebruary from "./components/months/February"; @@ -45,14 +49,38 @@ describe('generateRefTypesAsArrayPlugin plugin', async () => { $id: "#/components/months/February", } as const; - type RefTypes = [ + export type References = [ typeof componentsSchemasAnswerWithId, typeof componentsMonthsJanuaryWithId, typeof componentsMonthsFebruaryWithId, ]; - export default RefTypes;`); + export const referenceSchemas = [ + componentsSchemasAnswerWithId, + componentsMonthsJanuaryWithId, + componentsMonthsFebruaryWithId, + ]`); - expect(actual).toBe(expected); + expect(actualAsText).toBe(expectedAsText); + + // Ref schemas for fastify.addSchema + const answerSchema = await importFresh( + path.resolve(outputPath, 'components/schemas/Answer'), + ); + const januarySchema = await importFresh( + path.resolve(outputPath, 'components/months/January'), + ); + const februarySchema = await importFresh( + path.resolve(outputPath, 'components/months/February'), + ); + const actualParsed = await importFresh( + path.resolve(outputPath, 'fastifyTypeProvider'), + ); + + expect(actualParsed.referenceSchemas).toEqual([ + { ...answerSchema.default, $id: '#/components/schemas/Answer' }, + { ...januarySchema.default, $id: '#/components/months/January' }, + { ...februarySchema.default, $id: '#/components/months/February' }, + ]); }); }); diff --git a/test/refHandling-import.test.ts b/test/refHandling-import.test.ts index 74bf5cb..402ed02 100644 --- a/test/refHandling-import.test.ts +++ b/test/refHandling-import.test.ts @@ -25,6 +25,10 @@ describe('refHandling option === "import"', () => { it('Generates expected schema', async () => { const { outputPath } = await openapiToTsJsonSchema({ openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-refHandling-import', + ), definitionPathsToGenerateFrom, silent: true, refHandling: 'import', @@ -111,6 +115,10 @@ describe('refHandling option === "import"', () => { it('Generates expected $ref schemas', async () => { const { outputPath } = await openapiToTsJsonSchema({ openApiSchema: path.resolve(fixtures, 'complex/specs.yaml'), + outputPath: path.resolve( + fixtures, + 'complex/schemas-autogenerated-refHandling-import', + ), definitionPathsToGenerateFrom: ['paths'], silent: true, refHandling: 'import', diff --git a/test/test-utils/importFresh.ts b/test/test-utils/importFresh.ts index 7959021..752a4cb 100644 --- a/test/test-utils/importFresh.ts +++ b/test/test-utils/importFresh.ts @@ -1,4 +1,7 @@ -// https://github.com/small-tech/import-fresh +/** + * It doesn't work for sub-imports + * https://github.com/small-tech/import-fresh + */ export async function importFresh(absolutePathToModule: string) { const cacheBustingModulePath = `${absolutePathToModule}?update=${Date.now()}`; return await import(cacheBustingModulePath); diff --git a/vitest.config.mts b/vitest.config.mts index c5d369f..215d78b 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -7,6 +7,7 @@ export default defineConfig({ sequence: { hooks: 'stack', concurrent: false, + shuffle: true, }, coverage: { provider: 'istanbul',