diff --git a/__tests__/__snapshots__/openapi-schema.test.ts.snap b/__tests__/__snapshots__/openapi-schema.test.ts.snap index 564748f..c4f1f9b 100644 --- a/__tests__/__snapshots__/openapi-schema.test.ts.snap +++ b/__tests__/__snapshots__/openapi-schema.test.ts.snap @@ -159,6 +159,7 @@ exports[`Example > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -232,6 +233,7 @@ exports[`Example > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -242,7 +244,7 @@ exports[`Example > OpenAPI 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -260,6 +262,19 @@ exports[`Example > OpenAPI 1`] = ` }, {}, ], + "servers": [ + { + "url": "https://api.example.com/", + }, + ], + "tags": [ + { + "name": "user", + }, + { + "name": "random", + }, + ], } `; @@ -579,6 +594,7 @@ exports[`Example > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -763,6 +779,7 @@ exports[`Example > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -812,7 +829,7 @@ exports[`Example > Swagger Parser validate 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -830,6 +847,19 @@ exports[`Example > Swagger Parser validate 1`] = ` }, {}, ], + "servers": [ + { + "url": "https://api.example.com/", + }, + ], + "tags": [ + { + "name": "user", + }, + { + "name": "random", + }, + ], } `; @@ -1072,6 +1102,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1123,11 +1154,11 @@ exports[`Note Taking > OpenAPI 1`] = ` "responses": { "204": { "content": {}, - "description": "", + "description": "Successful response", }, "404": { "content": {}, - "description": "", + "description": "Successful response", }, }, "security": [ @@ -1151,6 +1182,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1161,7 +1193,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -1169,6 +1201,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, "/users/{userId}/notes": { "get": { + "description": "List all of the notes", "operationId": "listNotesCommand", "responses": { "200": { @@ -1182,6 +1215,7 @@ exports[`Note Taking > OpenAPI 1`] = ` "description": "Notes 200 response", }, }, + "summary": "List notes", "tags": [ "user", ], @@ -1192,6 +1226,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, ], "post": { + "deprecated": true, "operationId": "createNoteCommand", "requestBody": { "content": { @@ -1202,6 +1237,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1267,6 +1303,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1277,7 +1314,7 @@ exports[`Note Taking > OpenAPI 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -1300,6 +1337,16 @@ exports[`Note Taking > OpenAPI 1`] = ` ], }, ], + "servers": [ + { + "url": "https://api.example.com/", + }, + ], + "tags": [ + { + "name": "user", + }, + ], } `; @@ -1715,6 +1762,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1838,11 +1886,11 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` "responses": { "204": { "content": {}, - "description": "", + "description": "Successful response", }, "404": { "content": {}, - "description": "", + "description": "Successful response", }, }, "security": [ @@ -1897,6 +1945,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -1941,7 +1990,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -1949,6 +1998,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, "/users/{userId}/notes": { "get": { + "description": "List all of the notes", "operationId": "listNotesCommand", "responses": { "200": { @@ -1987,6 +2037,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` "description": "Notes 200 response", }, }, + "summary": "List notes", "tags": [ "user", ], @@ -2004,6 +2055,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, ], "post": { + "deprecated": true, "operationId": "createNoteCommand", "requestBody": { "content": { @@ -2035,6 +2087,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -2174,6 +2227,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, "description": "", + "required": true, }, "responses": { "200": { @@ -2205,7 +2259,7 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` }, }, }, - "description": "", + "description": "Successful response", }, }, "tags": [], @@ -2228,5 +2282,15 @@ exports[`Note Taking > Swagger Parser validate 1`] = ` ], }, ], + "servers": [ + { + "url": "https://api.example.com/", + }, + ], + "tags": [ + { + "name": "user", + }, + ], } `; diff --git a/__tests__/fixtures/apis/note-taking.ts b/__tests__/fixtures/apis/note-taking.ts index 271aba3..a05d2a5 100644 --- a/__tests__/fixtures/apis/note-taking.ts +++ b/__tests__/fixtures/apis/note-taking.ts @@ -272,6 +272,8 @@ new Path(noteTakingApi, { }) .addOperation(HttpMethods.GET, { operationId: 'listNotesCommand', + description: 'List all of the notes', + summary: 'List notes', responses: { 200: new Response(noteTakingApi, 'ListNotesResponse', { description: 'Notes 200 response', @@ -284,7 +286,9 @@ new Path(noteTakingApi, { }) .addOperation(HttpMethods.POST, { operationId: 'createNoteCommand', + deprecated: true, requestBody: { + required: true, content: { contentType: 'application/json', schema: createNoteRequest, diff --git a/__tests__/fixtures/oas31.json b/__tests__/fixtures/oas31.json index 778b2ed..9e14a91 100644 --- a/__tests__/fixtures/oas31.json +++ b/__tests__/fixtures/oas31.json @@ -55,25 +55,16 @@ "$ref": "#/$defs/external-documentation" } }, - "required": [ - "openapi", - "info" - ], + "required": ["openapi", "info"], "anyOf": [ { - "required": [ - "paths" - ] + "required": ["paths"] }, { - "required": [ - "components" - ] + "required": ["components"] }, { - "required": [ - "webhooks" - ] + "required": ["webhooks"] } ], "$ref": "#/$defs/specification-extensions", @@ -106,10 +97,7 @@ "type": "string" } }, - "required": [ - "title", - "version" - ], + "required": ["title", "version"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -147,15 +135,11 @@ "format": "uri" } }, - "required": [ - "name" - ], + "required": ["name"], "dependentSchemas": { "identifier": { "not": { - "required": [ - "url" - ] + "required": ["url"] } } }, @@ -180,9 +164,7 @@ } } }, - "required": [ - "url" - ], + "required": ["url"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -204,9 +186,7 @@ "type": "string" } }, - "required": [ - "default" - ], + "required": ["default"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -350,9 +330,7 @@ "path-item-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -433,9 +411,7 @@ "format": "uri" } }, - "required": [ - "url" - ], + "required": ["url"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -447,12 +423,7 @@ "type": "string" }, "in": { - "enum": [ - "query", - "header", - "path", - "cookie" - ] + "enum": ["query", "header", "path", "cookie"] }, "description": { "type": "string" @@ -474,20 +445,13 @@ "maxProperties": 1 } }, - "required": [ - "name", - "in" - ], + "required": ["name", "in"], "oneOf": [ { - "required": [ - "schema" - ] + "required": ["schema"] }, { - "required": [ - "content" - ] + "required": ["content"] } ], "if": { @@ -496,9 +460,7 @@ "const": "query" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -546,27 +508,19 @@ "const": "path" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { "style": { "default": "simple", - "enum": [ - "matrix", - "label", - "simple" - ] + "enum": ["matrix", "label", "simple"] }, "required": { "const": true } }, - "required": [ - "required" - ] + "required": ["required"] } }, "styles-for-header": { @@ -576,9 +530,7 @@ "const": "header" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -596,9 +548,7 @@ "const": "query" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -625,9 +575,7 @@ "const": "cookie" } }, - "required": [ - "in" - ] + "required": ["in"] }, "then": { "properties": { @@ -647,9 +595,7 @@ "parameter-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -673,18 +619,14 @@ "type": "boolean" } }, - "required": [ - "content" - ], + "required": ["content"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, "request-body-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -743,12 +685,7 @@ }, "style": { "default": "form", - "enum": [ - "form", - "spaceDelimited", - "pipeDelimited", - "deepObject" - ] + "enum": ["form", "spaceDelimited", "pipeDelimited", "deepObject"] }, "explode": { "type": "boolean" @@ -790,8 +727,8 @@ "^[1-5](?:[0-9]{2}|XX)$": false } }, - "then" : { - "required": [ "default" ] + "then": { + "required": ["default"] } }, "response": { @@ -817,18 +754,14 @@ } } }, - "required": [ - "description" - ], + "required": ["description"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, "response-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -848,9 +781,7 @@ "callbacks-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -876,10 +807,7 @@ } }, "not": { - "required": [ - "value", - "externalValue" - ] + "required": ["value", "externalValue"] }, "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false @@ -887,9 +815,7 @@ "example-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -922,14 +848,10 @@ }, "oneOf": [ { - "required": [ - "operationRef" - ] + "required": ["operationRef"] }, { - "required": [ - "operationId" - ] + "required": ["operationId"] } ], "$ref": "#/$defs/specification-extensions", @@ -938,9 +860,7 @@ "link-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -975,14 +895,10 @@ }, "oneOf": [ { - "required": [ - "schema" - ] + "required": ["schema"] }, { - "required": [ - "content" - ] + "required": ["content"] } ], "dependentSchemas": { @@ -1006,9 +922,7 @@ "header-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -1031,9 +945,7 @@ "$ref": "#/$defs/external-documentation" } }, - "required": [ - "name" - ], + "required": ["name"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1056,31 +968,20 @@ "schema": { "$comment": "https://spec.openapis.org/oas/v3.1.0#schema-object", "$dynamicAnchor": "meta", - "type": [ - "object", - "boolean" - ] + "type": ["object", "boolean"] }, "security-scheme": { "$comment": "https://spec.openapis.org/oas/v3.1.0#security-scheme-object", "type": "object", "properties": { "type": { - "enum": [ - "apiKey", - "http", - "mutualTLS", - "oauth2", - "openIdConnect" - ] + "enum": ["apiKey", "http", "mutualTLS", "oauth2", "openIdConnect"] }, "description": { "type": "string" } }, - "required": [ - "type" - ], + "required": ["type"], "allOf": [ { "$ref": "#/$defs/specification-extensions" @@ -1110,9 +1011,7 @@ "const": "apiKey" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1120,17 +1019,10 @@ "type": "string" }, "in": { - "enum": [ - "query", - "header", - "cookie" - ] + "enum": ["query", "header", "cookie"] } }, - "required": [ - "name", - "in" - ] + "required": ["name", "in"] } }, "type-http": { @@ -1140,9 +1032,7 @@ "const": "http" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1150,9 +1040,7 @@ "type": "string" } }, - "required": [ - "scheme" - ] + "required": ["scheme"] } }, "type-http-bearer": { @@ -1166,10 +1054,7 @@ "pattern": "^[Bb][Ee][Aa][Rr][Ee][Rr]$" } }, - "required": [ - "type", - "scheme" - ] + "required": ["type", "scheme"] }, "then": { "properties": { @@ -1186,9 +1071,7 @@ "const": "oauth2" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1196,9 +1079,7 @@ "$ref": "#/$defs/oauth-flows" } }, - "required": [ - "flows" - ] + "required": ["flows"] } }, "type-oidc": { @@ -1208,9 +1089,7 @@ "const": "openIdConnect" } }, - "required": [ - "type" - ] + "required": ["type"] }, "then": { "properties": { @@ -1219,9 +1098,7 @@ "format": "uri" } }, - "required": [ - "openIdConnectUrl" - ] + "required": ["openIdConnectUrl"] } } } @@ -1229,9 +1106,7 @@ "security-scheme-or-reference": { "if": { "type": "object", - "required": [ - "$ref" - ] + "required": ["$ref"] }, "then": { "$ref": "#/$defs/reference" @@ -1274,10 +1149,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "authorizationUrl", - "scopes" - ], + "required": ["authorizationUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1296,10 +1168,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "tokenUrl", - "scopes" - ], + "required": ["tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1318,10 +1187,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "tokenUrl", - "scopes" - ], + "required": ["tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false }, @@ -1344,11 +1210,7 @@ "$ref": "#/$defs/map-of-strings" } }, - "required": [ - "authorizationUrl", - "tokenUrl", - "scopes" - ], + "required": ["authorizationUrl", "tokenUrl", "scopes"], "$ref": "#/$defs/specification-extensions", "unevaluatedProperties": false } @@ -1394,9 +1256,7 @@ "const": "form" } }, - "required": [ - "style" - ] + "required": ["style"] }, "then": { "properties": { diff --git a/__tests__/regression.test.ts b/__tests__/regression.test.ts new file mode 100644 index 0000000..030c7ff --- /dev/null +++ b/__tests__/regression.test.ts @@ -0,0 +1,52 @@ +/* eslint-disable no-new */ +import { test } from 'vitest'; +import * as oac from '@block65/openapi-constructs'; + +test('Regression', async () => { + const api = new oac.Api({ + openapi: oac.OpenApiVersion.V3_1, + info: { + title: 'Example', + version: '1.0.0', + }, + }); + + const schema1 = new oac.Schema(api, 'Test1', { + schema: { + type: 'object', + additionalProperties: false, + minProperties: 1, + properties: { + description: { + oneOf: [ + { + type: ['string', 'null'], + minLength: 1, + maxLength: 1024, + }, + { + type: 'null', + }, + ], + }, + }, + }, + }); + + new oac.Schema(api, 'Test2', { + schema: { + allOf: [ + schema1.schema, + { + type: 'object', + required: ['name'], + properties: { + name: { + type: 'string', + }, + }, + }, + ], + }, + }); +}); diff --git a/examples/example.ts b/examples/example.ts new file mode 100644 index 0000000..d3162d7 --- /dev/null +++ b/examples/example.ts @@ -0,0 +1,207 @@ +/* eslint-disable no-new */ +import { + Api, + OpenApiVersion, + Parameter, + Path, + Reference, + Response, + Schema, + SecurityRequirement, + SecurityScheme, + Server, + Tag, + HttpMethods, +} from '@block65/openapi-constructs'; + +const api = new Api({ + openapi: OpenApiVersion.V3_1, + info: { + title: 'Example REST API', + version: '1.0.0', + }, +}); + +new Server(api, 'ExampleServer', { + url: new URL('https://api.example.com'), +}); + +const httpBearerJwtScheme = new SecurityScheme(api, 'HttpBearerJwtScheme', { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT', +}); + +new SecurityRequirement(api, 'AllScopes', { + securityScheme: httpBearerJwtScheme, + scopes: [], +}); + +const userTag = new Tag(api, 'UserTag', { + name: 'user', +}); + +const userDeleteScopeReq = new SecurityRequirement(api, 'UserDeleteScope', { + securityScheme: httpBearerJwtScheme, + scopes: ['users.delete'], +}); + +const noSecurityRequirement = new SecurityRequirement(api, 'NoSecurity'); + +const addressSchema = new Schema(api, 'Address', { + schema: { + type: 'object', + required: ['name'], + additionalProperties: false, + properties: { + postcode: { + type: 'integer', + format: 'int32', + minimum: 1000, + maximum: 9999, + }, + }, + }, +}); + +const idSchema = new Schema(api, 'Id', { + schema: { + type: 'string', + minLength: 6, + maxLength: 6, + }, +}); + +const user = new Schema(api, 'User', { + schema: { + type: 'object', + required: ['name'], + additionalProperties: false, + properties: { + userId: idSchema.referenceObject(), + name: { + type: 'string', + }, + address: addressSchema.referenceObject(), + age: { + type: 'integer', + format: 'int32', + minimum: 0, + }, + }, + }, +}); + +const updateUserRequest = new Schema(api, 'UpdateUserRequest', { + schema: { + type: 'object', + minProperties: 1, + additionalProperties: false, + properties: { + address: addressSchema.referenceObject(), + age: { + type: 'integer', + format: 'int32', + minimum: 0, + }, + }, + examples: [{ age: 10, address: { postcode: 2000 } }], + }, +}); + +const createUserRequest = new Reference(user, 'CreateUserRequest'); + +const users = new Schema(api, 'Users', { + schema: { + type: 'array', + uniqueItems: true, + items: user.referenceObject(), + examples: [[1, 2, 3]], + }, +}); + +const userIdParameter = new Parameter(api, 'UserId', { + name: 'userId', + in: 'path', + required: true, + schema: idSchema, +}); + +new Path(api, { + path: '/users', + tags: new Set([userTag]), +}) + .addOperation(HttpMethods.GET, { + operationId: 'listUsersCommand', + responses: { + 200: new Response(api, 'ListUsersResponse', { + description: 'User 200 response', + content: { + contentType: 'application/json', + schema: users, + }, + }), + }, + }) + .addOperation(HttpMethods.POST, { + operationId: 'createUserCommand', + requestBody: { + content: { + contentType: 'application/json', + schema: createUserRequest, + }, + }, + responses: { + 200: new Response(api, 'CreateUserResponse', { + description: 'User 200 response', + content: { + contentType: 'application/json', + schema: users, + }, + }), + }, + }); + +new Path(api, { + path: '/users/{userId}', + parameters: [userIdParameter], +}) + .addOperation(HttpMethods.GET, { + operationId: 'getUserByIdCommand', + responses: { + 200: new Response(api, 'GetUserById', { + description: 'User 200 response', + content: { + contentType: 'application/json', + schema: user, + }, + }), + }, + }) + .addOperation(HttpMethods.DELETE, { + operationId: 'deleteUserByIdCommand', + security: userDeleteScopeReq, + }) + .addOperation(HttpMethods.HEAD, { + operationId: 'checkUserIdAvailableCommand', + security: noSecurityRequirement, + }) + .addOperation(HttpMethods.POST, { + operationId: 'updateUserCommand', + requestBody: { + content: { + contentType: 'application/json', + schema: updateUserRequest, + }, + }, + responses: { + 200: new Response(api, 'UpdateUserResponse', { + content: { + contentType: 'application/json', + schema: user, + }, + }), + }, + }); + +process.stdout.write(JSON.stringify(api.synth(), null, 2)); diff --git a/lib/api.ts b/lib/api.ts index 2533415..fa12211 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -7,6 +7,8 @@ import { Reference } from './reference.js'; import { Schema } from './schema.js'; import { SecurityRequirement } from './security-requirement.js'; import { SecurityScheme } from './security-scheme.js'; +import { Server } from './server.js'; +import { Tag } from './tag.js'; export enum OpenApiVersion { // V2 = '2.0', @@ -33,6 +35,12 @@ export class Api extends ApiLowLevel { return { openapi: this.options.openapi, info: this.options.info, + servers: this.node.children + .filter((child): child is Server => child instanceof Server) + .map((child) => child.synth()), + tags: this.node.children + .filter((child): child is Tag => child instanceof Tag) + .map((child) => child.synth()), components: { schemas: Object.fromEntries( this.node.children diff --git a/lib/index.ts b/lib/index.ts index c0019c8..831c218 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -12,7 +12,7 @@ export { Parameter } from './parameter.js'; export { Path } from './path.js'; export { Reference } from './reference.js'; export { Response } from './response.js'; -export { Schema } from './schema.js'; +export { Schema, type SchemaOptions } from './schema.js'; export { SecurityRequirement } from './security-requirement.js'; export { SecurityScheme } from './security-scheme.js'; export { Server } from './server.js'; diff --git a/lib/operation.ts b/lib/operation.ts index 3fa43e0..06587d7 100644 --- a/lib/operation.ts +++ b/lib/operation.ts @@ -7,6 +7,7 @@ import type { Response } from './response.js'; import type { SecurityRequirement } from './security-requirement.js'; import type { Tag } from './tag.js'; import type { ExtractRouteParams } from './types.js'; +import { stripUndefined } from './utils.js'; export interface OperationOptions { operationId: string; @@ -20,6 +21,7 @@ export interface OperationOptions { [statusCode: string | number]: Response; }; requestBody?: RequestBody | RequestBodyOptions; + order?: number; } export class Operation extends Construct { @@ -27,6 +29,8 @@ export class Operation extends Construct { public readonly method: HttpMethods; + public readonly order: number; + private requestBody?: RequestBody; public hasOperationId(operationId: OperationOptions['operationId']): boolean { @@ -42,6 +46,8 @@ export class Operation extends Construct { this.method = method; this.options = options; + this.order = options.order || 0; + if (options.requestBody) { this.requestBody = options.requestBody instanceof RequestBody @@ -68,10 +74,13 @@ export class Operation extends Construct { } public synth() { - return { - ...(this.options.operationId && { - operationId: this.options.operationId, - }), + return stripUndefined({ + operationId: this.options.operationId, + description: this.options.description || undefined, + summary: this.options.summary || undefined, + tags: + this.options.tags && [...this.options.tags].map((child) => child.name), + deprecated: this.options.deprecated, ...(this.options.parameters && { parameters: this.options.parameters.map((child) => child.synth()), }), @@ -92,6 +101,6 @@ export class Operation extends Construct { ]), ), }), - } satisfies oas31.OperationObject; + }) satisfies oas31.OperationObject; } } diff --git a/lib/path.ts b/lib/path.ts index b01a8a2..f8be568 100644 --- a/lib/path.ts +++ b/lib/path.ts @@ -1,4 +1,3 @@ -import { strict } from 'node:assert'; import { Construct } from 'constructs'; import type { oas31 } from 'openapi3-ts'; import type { Api } from './api.js'; @@ -30,7 +29,7 @@ export class Path extends Construct { options: OperationOptions, ): this { // make sure we are not duplicating tags - options.tags?.forEach((tag) => strict(!this.options.tags?.has(tag))); + // options.tags?.forEach((tag) => strict(!this.options.tags?.has(tag))); // eslint-disable-next-line no-new new Operation(this, method, { @@ -52,6 +51,7 @@ export class Path extends Construct { .filter( (child): child is Operation => child instanceof Operation, ) + .sort((a, b) => a.order - b.order) .map((child) => [child.method, child.synth()]), ), ...(this.options.servers && { diff --git a/lib/request-body.ts b/lib/request-body.ts index c4a8bcb..eeb6568 100644 --- a/lib/request-body.ts +++ b/lib/request-body.ts @@ -15,7 +15,10 @@ export class RequestBody extends Construct { constructor(scope: Construct, id: string, options: RequestBodyOptions) { super(scope, id); - this.options = options; + this.options = { + required: true, + ...options, + }; this.content = options.content instanceof MediaType diff --git a/lib/response.ts b/lib/response.ts index d336935..aee9ee1 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -28,7 +28,7 @@ export class Response extends Construct { public synth() { return { - description: this.options.description || '', + description: this.options.description || 'Successful response', content: { ...(this.content && { [this.content.contentType]: this.content.synth(), diff --git a/lib/schema.ts b/lib/schema.ts index 33af78b..0e32425 100644 --- a/lib/schema.ts +++ b/lib/schema.ts @@ -1,12 +1,36 @@ import { Construct } from 'constructs'; import type { oas31 } from 'openapi3-ts'; -interface SchemaOptions { - schema: T; -} +type InferExample = T extends oas31.ReferenceObject + ? any + : T extends { + type: 'string'; + } + ? string + : T extends { + type: 'number' | 'integer'; + } + ? number + : T extends { + type: 'boolean'; + } + ? boolean + : T extends oas31.SchemaObject & { type: 'object' } + ? { + [K in keyof T['properties']]: InferExample; + } + : T extends oas31.SchemaObject & { type: 'array' } + ? InferExample[] + : any; + +export type SchemaOptions = { + schema: T & { + examples?: InferExample[]; + }; +}; export class Schema< - T extends oas31.SchemaObject = oas31.SchemaObject, + const T extends oas31.SchemaObject = oas31.SchemaObject, > extends Construct { private options: SchemaOptions; @@ -40,6 +64,12 @@ export class Schema< } public synth() { - return this.options.schema; + return { + // default to disallow additional properties on objects + ...(this.options.schema.type === 'object' && { + additionalProperties: false, + }), + ...this.options.schema, + }; } } diff --git a/lib/tag.ts b/lib/tag.ts index 2b57d65..631bdd6 100644 --- a/lib/tag.ts +++ b/lib/tag.ts @@ -1,25 +1,21 @@ import { Construct } from 'constructs'; import type { oas31 } from 'openapi3-ts'; -interface TagOptions { - name: string; - description?: string; - externalDocs?: oas31.ExternalDocumentationObject; -} - -export class Tag extends Construct { - private options: TagOptions; +export class Tag< + T extends oas31.TagObject = oas31.TagObject, +> extends Construct { + private options: T; public get name() { return this.options.name; } - constructor(scope: Construct, id: string, options: TagOptions) { + constructor(scope: Construct, id: string, options: T) { super(scope, id); this.options = options; } - public synth(): oas31.TagObject { + public synth() { return this.options; } } diff --git a/lib/utils.ts b/lib/utils.ts new file mode 100644 index 0000000..b1efeb5 --- /dev/null +++ b/lib/utils.ts @@ -0,0 +1,14 @@ +export type WithoutUndefinedProperties = { + [P in keyof T]: Exclude; +}; + +export type OptionalToUndefined = { + [P in keyof T]: undefined extends T[P] ? T[P] | undefined : T[P]; +}; + +// this is a better version that uses Object.fromEntries +export function stripUndefined(obj: OptionalToUndefined) { + return Object.fromEntries( + Object.entries(obj).filter(([, v]) => typeof v !== 'undefined'), + ) as WithoutUndefinedProperties; +} diff --git a/tsconfig.json b/tsconfig.json index e8195f5..7cafcbf 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -25,6 +25,6 @@ "allowArbitraryExtensions": true, "skipLibCheck": true }, - "include": ["./lib", "./__tests__"], + "include": ["./lib", "./examples", "./__tests__"], "exclude": ["./dist", "node_modules"] }