From b678013a299e5ee9e70b6a6ebd3e9d9f8cea667a Mon Sep 17 00:00:00 2001 From: Aryaman Dhingra Date: Wed, 31 Jul 2024 16:31:26 -0400 Subject: [PATCH 1/3] feat: support private fields in the openapi generator DX-613 --- packages/openapi-generator/src/openapi.ts | 15 +- .../openapi-generator/test/openapi.test.ts | 176 ++++++++---------- 2 files changed, 89 insertions(+), 102 deletions(-) diff --git a/packages/openapi-generator/src/openapi.ts b/packages/openapi-generator/src/openapi.ts index aa46964c..14272931 100644 --- a/packages/openapi-generator/src/openapi.ts +++ b/packages/openapi-generator/src/openapi.ts @@ -7,7 +7,7 @@ import type { Route } from './route'; import type { Schema } from './ir'; import { Block } from 'comment-parser'; -function schemaToOpenAPI( +export function schemaToOpenAPI( schema: Schema, ): OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject | undefined { schema = optimize(schema); @@ -226,8 +226,10 @@ function schemaToOpenAPI( const format = jsdoc?.tags?.format ?? schema.format ?? schema.format; const title = jsdoc?.tags?.title ?? schema.title; - const deprecated = - Object.keys(jsdoc?.tags || {}).includes('deprecated') || !!schema.deprecated; + const keys = Object.keys(jsdoc?.tags || {}); + + const deprecated = keys.includes('deprecated') || !!schema.deprecated; + const isPrivate = keys.includes('private'); const description = schema.comment?.description ?? schema.description; const defaultOpenAPIObject = { @@ -252,6 +254,7 @@ function schemaToOpenAPI( ...(writeOnly ? { writeOnly: true } : {}), ...(format ? { format } : {}), ...(title ? { title } : {}), + ...(isPrivate ? { 'x-internal': true } : {}), }; return defaultOpenAPIObject; @@ -322,12 +325,18 @@ function routeToOpenAPI(route: Route): [string, string, OpenAPIV3.OperationObjec delete schema.description; } + const isPrivate = schema && 'x-internal' in schema; + if (isPrivate) { + delete schema['x-internal']; + } + return { name: p.name, ...(p.schema?.comment?.description !== undefined ? { description: p.schema.comment.description } : {}), in: p.type, + ...(isPrivate ? { 'x-internal': true } : {}), ...(p.required ? { required: true } : {}), ...(p.explode ? { style: 'form', explode: true } : {}), schema: schema as any, // TODO: Something to disallow arrays diff --git a/packages/openapi-generator/test/openapi.test.ts b/packages/openapi-generator/test/openapi.test.ts index 55bbbd47..8ccc1d6d 100644 --- a/packages/openapi-generator/test/openapi.test.ts +++ b/packages/openapi-generator/test/openapi.test.ts @@ -1,7 +1,6 @@ import * as E from 'fp-ts/lib/Either'; import assert from 'node:assert/strict'; import test from 'node:test'; -import { OpenAPIV3_1 } from 'openapi-types'; import { convertRoutesToOpenAPI, @@ -17,11 +16,7 @@ import { SourceFile } from '../src/sourceFile'; async function testCase( description: string, src: string, - expected: OpenAPIV3_1.Document<{ - 'x-internal'?: boolean; - 'x-unstable'?: boolean; - 'x-unknown-tags'?: object; - }>, + expected: any, expectedErrors: string[] = [], ) { test(description, async () => { @@ -3940,140 +3935,123 @@ testCase("route with nested array examples", ROUTE_WITH_NESTED_ARRAY_EXAMPLES, { } }); -const ROUTE_WITH_RECORD_TYPES = ` +const ROUTE_WITH_PRIVATE_PROPERTIES = ` import * as t from 'io-ts'; import * as h from '@api-ts/io-ts-http'; -const ValidKeys = t.keyof({ name: "name", age: "age", address: "address" }); -const PersonObject = t.type({ bigName: t.string, bigAge: t.number }); +const SampleType = t.type({ + foo: t.string, + /** @private */ + bar: t.string, // This should show up with x-internal +}); export const route = h.httpRoute({ path: '/foo', method: 'GET', - request: h.httpRequest({ + request: h.httpRequest({ + params: { + /** @private */ + path: t.string + }, query: { - name: t.string, + /** @private */ + query: t.string }, + body: SampleType }), response: { - 200: { - person: t.record(ValidKeys, t.string), - anotherPerson: t.record(ValidKeys, PersonObject), - bigPerson: t.record(t.string, t.string), - anotherBigPerson: t.record(t.string, PersonObject), - } + 200: SampleType }, }); -`; +` -testCase("route with record types", ROUTE_WITH_RECORD_TYPES, { - openapi: '3.0.3', +testCase("route with private properties in request query, params, body, and response", ROUTE_WITH_PRIVATE_PROPERTIES, { + openapi: "3.0.3", info: { - title: 'Test', - version: '1.0.0' + title: "Test", + version: "1.0.0" }, paths: { '/foo': { get: { parameters: [ { - name: 'name', + 'x-internal': true, + description: '', in: 'query', + name: 'query', + required: true, + schema: { + type: 'string' + } + }, + { + 'x-internal': true, + description: '', + in: 'path', + name: 'path', required: true, schema: { type: 'string' } } ], + requestBody: { + content: { + 'application/json': { + schema: { + properties: { + bar: { + 'x-internal': true, + type: 'string' + }, + foo: { + type: 'string' + } + }, + required: [ + 'foo', + 'bar' + ], + type: 'object' + } + } + }, + }, responses: { '200': { - description: 'OK', content: { 'application/json': { schema: { - type: 'object', - properties: { - // becomes t.type() - person: { - type: 'object', - properties: { - name: { type: 'string' }, - age: { type: 'string' }, - address: { type: 'string' } - }, - required: [ 'name', 'age', 'address' ] - }, - // becomes t.type() - anotherPerson: { - type: 'object', - properties: { - name: { - type: 'object', - properties: { - bigName: { type: 'string' }, - bigAge: { type: 'number' } - }, - required: [ 'bigName', 'bigAge' ] - }, - age: { - type: 'object', - properties: { - bigName: { type: 'string' }, - bigAge: { type: 'number' } - }, - required: [ 'bigName', 'bigAge' ] - }, - address: { - type: 'object', - properties: { - bigName: { type: 'string' }, - bigAge: { type: 'number' } - }, - required: [ 'bigName', 'bigAge' ] - } - }, - required: [ 'name', 'age', 'address' ] - }, - bigPerson: { - // stays as t.record() - type: 'object', - additionalProperties: { type: 'string' } - }, - anotherBigPerson: { - // stays as t.record() - type: 'object', - additionalProperties: { - type: 'object', - properties: { - bigName: { type: 'string' }, - bigAge: { type: 'number' } - }, - required: [ 'bigName', 'bigAge' ] - } - } - }, - required: [ 'person', 'anotherPerson', 'bigPerson', 'anotherBigPerson' ] + '$ref': '#/components/schemas/SampleType' } } - } + }, + description: 'OK' } } } - } + }, }, components: { schemas: { - ValidKeys: { - title: 'ValidKeys', - type: 'string', - enum: [ 'name', 'age', 'address' ] - }, - PersonObject: { - title: 'PersonObject', - type: 'object', - properties: { bigName: { type: 'string' }, bigAge: { type: 'number' } }, - required: [ 'bigName', 'bigAge' ] + SampleType: { + properties: { + bar: { + 'x-internal': true, + type: 'string' + }, + foo: { + type: 'string' + } + }, + required: [ + 'foo', + 'bar' + ], + title: 'SampleType', + type: 'object' } } - } + }, }); From d0f7e23f6b626f4dc4647e88d0dea5848ae8af4e Mon Sep 17 00:00:00 2001 From: Aryaman Dhingra Date: Thu, 1 Aug 2024 10:54:28 -0400 Subject: [PATCH 2/3] test: add privateObject to private field test DX-613 --- .../openapi-generator/test/openapi.test.ts | 38 +++++++++++++++++-- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/openapi-generator/test/openapi.test.ts b/packages/openapi-generator/test/openapi.test.ts index 8ccc1d6d..159f294a 100644 --- a/packages/openapi-generator/test/openapi.test.ts +++ b/packages/openapi-generator/test/openapi.test.ts @@ -3942,7 +3942,11 @@ import * as h from '@api-ts/io-ts-http'; const SampleType = t.type({ foo: t.string, /** @private */ - bar: t.string, // This should show up with x-internal + bar: t.string, // This should show up with x-internal, + /** @private */ + privateObject: t.type({ + privateFieldInObject: t.boolean + }) }); export const route = h.httpRoute({ @@ -3963,7 +3967,7 @@ export const route = h.httpRoute({ 200: SampleType }, }); -` +`; testCase("route with private properties in request query, params, body, and response", ROUTE_WITH_PRIVATE_PROPERTIES, { openapi: "3.0.3", @@ -4007,11 +4011,24 @@ testCase("route with private properties in request query, params, body, and resp }, foo: { type: 'string' + }, + privateObject: { + 'x-internal': true, + properties: { + privateFieldInObject: { + type: 'boolean' + } + }, + required: [ + 'privateFieldInObject' + ], + type: 'object' } }, required: [ 'foo', - 'bar' + 'bar', + 'privateObject' ], type: 'object' } @@ -4043,11 +4060,24 @@ testCase("route with private properties in request query, params, body, and resp }, foo: { type: 'string' + }, + privateObject: { + 'x-internal': true, + properties: { + privateFieldInObject: { + type: 'boolean' + } + }, + required: [ + 'privateFieldInObject' + ], + type: 'object' } }, required: [ 'foo', - 'bar' + 'bar', + 'privateObject' ], title: 'SampleType', type: 'object' From 377b3313e2c9076b68ffe19e870149cd93407897 Mon Sep 17 00:00:00 2001 From: Aryaman Dhingra Date: Thu, 1 Aug 2024 15:15:32 -0400 Subject: [PATCH 3/3] test: add back lost test DX-613 --- .../openapi-generator/test/openapi.test.ts | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/packages/openapi-generator/test/openapi.test.ts b/packages/openapi-generator/test/openapi.test.ts index 159f294a..d65d3159 100644 --- a/packages/openapi-generator/test/openapi.test.ts +++ b/packages/openapi-generator/test/openapi.test.ts @@ -4085,3 +4085,139 @@ testCase("route with private properties in request query, params, body, and resp } }, }); + +const ROUTE_WITH_RECORD_TYPES = ` +import * as t from 'io-ts'; +import * as h from '@api-ts/io-ts-http'; +const ValidKeys = t.keyof({ name: "name", age: "age", address: "address" }); +const PersonObject = t.type({ bigName: t.string, bigAge: t.number }); +export const route = h.httpRoute({ + path: '/foo', + method: 'GET', + request: h.httpRequest({ + query: { + name: t.string, + }, + }), + response: { + 200: { + person: t.record(ValidKeys, t.string), + anotherPerson: t.record(ValidKeys, PersonObject), + bigPerson: t.record(t.string, t.string), + anotherBigPerson: t.record(t.string, PersonObject), + } + }, +}); +`; + +testCase("route with record types", ROUTE_WITH_RECORD_TYPES, { + openapi: '3.0.3', + info: { + title: 'Test', + version: '1.0.0' + }, + paths: { + '/foo': { + get: { + parameters: [ + { + name: 'name', + in: 'query', + required: true, + schema: { + type: 'string' + } + } + ], + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + // becomes t.type() + person: { + type: 'object', + properties: { + name: { type: 'string' }, + age: { type: 'string' }, + address: { type: 'string' } + }, + required: [ 'name', 'age', 'address' ] + }, + // becomes t.type() + anotherPerson: { + type: 'object', + properties: { + name: { + type: 'object', + properties: { + bigName: { type: 'string' }, + bigAge: { type: 'number' } + }, + required: [ 'bigName', 'bigAge' ] + }, + age: { + type: 'object', + properties: { + bigName: { type: 'string' }, + bigAge: { type: 'number' } + }, + required: [ 'bigName', 'bigAge' ] + }, + address: { + type: 'object', + properties: { + bigName: { type: 'string' }, + bigAge: { type: 'number' } + }, + required: [ 'bigName', 'bigAge' ] + } + }, + required: [ 'name', 'age', 'address' ] + }, + bigPerson: { + // stays as t.record() + type: 'object', + additionalProperties: { type: 'string' } + }, + anotherBigPerson: { + // stays as t.record() + type: 'object', + additionalProperties: { + type: 'object', + properties: { + bigName: { type: 'string' }, + bigAge: { type: 'number' } + }, + required: [ 'bigName', 'bigAge' ] + } + } + }, + required: [ 'person', 'anotherPerson', 'bigPerson', 'anotherBigPerson' ] + } + } + } + } + } + } + } + }, + components: { + schemas: { + ValidKeys: { + title: 'ValidKeys', + type: 'string', + enum: [ 'name', 'age', 'address' ] + }, + PersonObject: { + title: 'PersonObject', + type: 'object', + properties: { bigName: { type: 'string' }, bigAge: { type: 'number' } }, + required: [ 'bigName', 'bigAge' ] + } + } + } +}); \ No newline at end of file