diff --git a/__tests__/__snapshots__/json-schema.test.ts.snap b/__tests__/__snapshots__/json-schema.test.ts.snap index 4f92805..9692155 100644 --- a/__tests__/__snapshots__/json-schema.test.ts.snap +++ b/__tests__/__snapshots__/json-schema.test.ts.snap @@ -19,6 +19,10 @@ exports[`Example > JSON Schema snapshot 1`] = ` ], "type": "object", }, + "Binary": { + "format": "binary", + "type": "string", + }, "CreateUserRequest": { "additionalProperties": false, "properties": { @@ -52,6 +56,11 @@ exports[`Example > JSON Schema snapshot 1`] = ` "Id": { "type": "string", }, + "RateLimit": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, "UpdateUserRequest": { "additionalProperties": false, "minProperties": 1, diff --git a/__tests__/__snapshots__/openapi-schema.test.ts.snap b/__tests__/__snapshots__/openapi-schema.test.ts.snap index 7d44f21..d91ac6d 100644 --- a/__tests__/__snapshots__/openapi-schema.test.ts.snap +++ b/__tests__/__snapshots__/openapi-schema.test.ts.snap @@ -3,6 +3,21 @@ exports[`Example > OpenAPI 1`] = ` { "components": { + "headers": { + "x-rate-limit": { + "description": "Number of requests allowed per hour", + "required": true, + "schema": { + "$ref": "#/components/schemas/RateLimit", + }, + }, + "x-rate-limit-remaining": { + "description": "Number of requests remaining in the current window", + "schema": { + "$ref": "#/components/schemas/RateLimit", + }, + }, + }, "parameters": { "UserId": { "in": "path", @@ -29,6 +44,10 @@ exports[`Example > OpenAPI 1`] = ` ], "type": "object", }, + "Binary": { + "format": "binary", + "type": "string", + }, "CreateUserRequest": { "additionalProperties": false, "properties": { @@ -62,6 +81,11 @@ exports[`Example > OpenAPI 1`] = ` "Id": { "type": "string", }, + "RateLimit": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, "UpdateUserRequest": { "additionalProperties": false, "minProperties": 1, @@ -142,6 +166,21 @@ exports[`Example > OpenAPI 1`] = ` }, }, "description": "User 200 response", + "headers": { + "x-rate-limit": { + "description": "Number of requests allowed per hour", + "required": true, + "schema": { + "$ref": "#/components/schemas/RateLimit", + }, + }, + "x-rate-limit-remaining": { + "description": "Number of requests remaining in the current window", + "schema": { + "$ref": "#/components/schemas/RateLimit", + }, + }, + }, }, }, "tags": [ @@ -250,6 +289,63 @@ exports[`Example > OpenAPI 1`] = ` "tags": [], }, }, + "/users/{userId}/avatar": { + "get": { + "description": "Download user avatar as JSON metadata or raw binary", + "operationId": "getUserAvatarCommand", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User", + }, + }, + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/Binary", + }, + }, + }, + "description": "Avatar response", + }, + }, + "tags": [], + }, + "parameters": [ + { + "$ref": "#/components/parameters/UserId", + }, + ], + "put": { + "description": "Upload user avatar as binary", + "operationId": "uploadUserAvatarCommand", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "$ref": "#/components/schemas/Binary", + }, + }, + }, + "description": "", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/User", + }, + }, + }, + "description": "Successful response", + }, + }, + "tags": [], + }, + }, }, "security": [ { @@ -281,6 +377,25 @@ exports[`Example > OpenAPI 1`] = ` exports[`Example > Swagger Parser validate 1`] = ` { "components": { + "headers": { + "x-rate-limit": { + "description": "Number of requests allowed per hour", + "required": true, + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + }, + "x-rate-limit-remaining": { + "description": "Number of requests remaining in the current window", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + }, + }, "parameters": { "UserId": { "in": "path", @@ -307,6 +422,10 @@ exports[`Example > Swagger Parser validate 1`] = ` ], "type": "object", }, + "Binary": { + "format": "binary", + "type": "string", + }, "CreateUserRequest": { "additionalProperties": false, "properties": { @@ -352,6 +471,11 @@ exports[`Example > Swagger Parser validate 1`] = ` "Id": { "type": "string", }, + "RateLimit": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, "UpdateUserRequest": { "additionalProperties": false, "minProperties": 1, @@ -538,6 +662,25 @@ exports[`Example > Swagger Parser validate 1`] = ` }, }, "description": "User 200 response", + "headers": { + "x-rate-limit": { + "description": "Number of requests allowed per hour", + "required": true, + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + }, + "x-rate-limit-remaining": { + "description": "Number of requests remaining in the current window", + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + }, + }, }, }, "tags": [ @@ -835,6 +978,148 @@ exports[`Example > Swagger Parser validate 1`] = ` "tags": [], }, }, + "/users/{userId}/avatar": { + "get": { + "description": "Download user avatar as JSON metadata or raw binary", + "operationId": "getUserAvatarCommand", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "postcode": { + "format": "int32", + "maximum": 9999, + "minimum": 1000, + "type": "integer", + }, + }, + "required": [ + "postcode", + ], + "type": "object", + }, + "age": { + "anyOf": [ + { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + { + "type": "null", + }, + ], + }, + "name": { + "type": "string", + }, + "userId": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + }, + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string", + }, + }, + }, + "description": "Avatar response", + }, + }, + "tags": [], + }, + "parameters": [ + { + "in": "path", + "name": "userId", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "put": { + "description": "Upload user avatar as binary", + "operationId": "uploadUserAvatarCommand", + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string", + }, + }, + }, + "description": "", + "required": true, + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "additionalProperties": false, + "properties": { + "address": { + "additionalProperties": false, + "properties": { + "postcode": { + "format": "int32", + "maximum": 9999, + "minimum": 1000, + "type": "integer", + }, + }, + "required": [ + "postcode", + ], + "type": "object", + }, + "age": { + "anyOf": [ + { + "format": "int32", + "minimum": 0, + "type": "integer", + }, + { + "type": "null", + }, + ], + }, + "name": { + "type": "string", + }, + "userId": { + "type": "string", + }, + }, + "required": [ + "name", + ], + "type": "object", + }, + }, + }, + "description": "Successful response", + }, + }, + "tags": [], + }, + }, }, "security": [ { @@ -866,6 +1151,7 @@ exports[`Example > Swagger Parser validate 1`] = ` exports[`Note Taking > OpenAPI 1`] = ` { "components": { + "headers": {}, "parameters": { "NoteId": { "in": "path", @@ -1353,6 +1639,7 @@ exports[`Note Taking > OpenAPI 1`] = ` exports[`Note Taking > Swagger Parser validate 1`] = ` { "components": { + "headers": {}, "parameters": { "NoteId": { "in": "path", diff --git a/__tests__/fixtures/apis/example.ts b/__tests__/fixtures/apis/example.ts index 8aca48d..5208c14 100644 --- a/__tests__/fixtures/apis/example.ts +++ b/__tests__/fixtures/apis/example.ts @@ -1,6 +1,7 @@ /* eslint-disable no-new */ import { Api, + Header, Schema, Parameter, Path, @@ -154,6 +155,32 @@ const users = new Schema(exampleApi, 'Users', { schema: idSchema, }); */ +const rateLimitSchema = new Schema(exampleApi, 'RateLimit', { + schema: { + type: 'integer', + format: 'int32', + minimum: 0, + }, +}); + +const rateLimitHeader = new Header(exampleApi, 'x-rate-limit', { + description: 'Number of requests allowed per hour', + required: true, + schema: rateLimitSchema, +}); + +const rateLimitRemainingHeader = new Header(exampleApi, 'x-rate-limit-remaining', { + description: 'Number of requests remaining in the current window', + schema: rateLimitSchema, +}); + +const binarySchema = new Schema(exampleApi, 'Binary', { + schema: { + type: 'string', + format: 'binary', + }, +}); + const userIdParameter = new Parameter(exampleApi, 'UserId', { name: 'userId', in: 'path', @@ -193,6 +220,10 @@ new Path(exampleApi, { contentType: 'application/json', schema: users, }, + headers: { + 'x-rate-limit': rateLimitHeader, + 'x-rate-limit-remaining': rateLimitRemainingHeader, + }, }), }, }) @@ -258,3 +289,45 @@ new Path(exampleApi, { }), }, }); + +new Path(exampleApi, { + path: '/users/{userId}/avatar', + parameters: [userIdParameter], +}) + .addOperation("get", { + operationId: 'getUserAvatarCommand', + description: 'Download user avatar as JSON metadata or raw binary', + responses: { + 200: new Response(exampleApi, 'GetUserAvatar200Response', { + description: 'Avatar response', + content: [ + { + contentType: 'application/json', + schema: user, + }, + { + contentType: 'application/octet-stream', + schema: binarySchema, + }, + ], + }), + }, + }) + .addOperation("put", { + operationId: 'uploadUserAvatarCommand', + description: 'Upload user avatar as binary', + requestBody: { + content: { + contentType: 'application/octet-stream', + schema: binarySchema, + }, + }, + responses: { + 200: new Response(exampleApi, 'UploadUserAvatar200Response', { + content: { + contentType: 'application/json', + schema: user, + }, + }), + }, + }); diff --git a/__tests__/parameter-types.test-d.ts b/__tests__/parameter-types.test-d.ts index 929fe35..fea21b0 100644 --- a/__tests__/parameter-types.test-d.ts +++ b/__tests__/parameter-types.test-d.ts @@ -14,7 +14,12 @@ expectTypeOf>().toExtend< ValidParameter<'/users/{userId}'> >(); -// Test that header parameters can be any string +// Test that lowercase header parameters are allowed +expectTypeOf>().toExtend< + ValidParameter<'/users/{userId}'> +>(); + +// @ts-expect-error uppercase header parameter names are not allowed (HTTP/2) expectTypeOf>().toExtend< ValidParameter<'/users/{userId}'> >(); diff --git a/lib/api.ts b/lib/api.ts index eaf4357..2cae57a 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -1,6 +1,7 @@ import type { JSONSchema7 } from 'json-schema'; import type { oas31 } from 'openapi3-ts'; import { ApiLowLevel } from './ApiLowLevel.ts'; +import { Header } from './header.ts'; import { Parameter } from './parameter.ts'; import { Path } from './path.ts'; import { Reference } from './reference.ts'; @@ -59,6 +60,11 @@ export class Api extends ApiLowLevel { ) .map((child) => [child.schemaKey, child.synth()]), ), + headers: Object.fromEntries( + this.node.children + .filter((child): child is Header => child instanceof Header) + .map((child) => [child.schemaKey, child.synth()]), + ), }, paths: Object.fromEntries( this.node.children diff --git a/lib/header.ts b/lib/header.ts index 37a528f..3ae9b80 100644 --- a/lib/header.ts +++ b/lib/header.ts @@ -1,3 +1,66 @@ import { Construct } from 'constructs'; +import type { oas31 } from 'openapi3-ts'; +import type { Schema } from './schema.ts'; -export class Header extends Construct {} +interface HeaderOptions { + description?: string; + required?: boolean; + deprecated?: boolean; + allowEmptyValue?: boolean; + style?: 'simple'; + explode?: boolean; + allowReserved?: boolean; + schema: Schema; +} + +export class Header extends Construct { + private options: HeaderOptions; + + constructor(scope: Construct, id: TName & Lowercase, options: HeaderOptions) { + super(scope, id); + this.options = options; + } + + public referenceObject(): oas31.ReferenceObject { + return { + $ref: this.jsonPointer(), + }; + } + + public get schemaKey() { + return this.node.id; + } + + public jsonPointer(): string { + return `#/components/headers/${this.schemaKey}`; + } + + public synth() { + return { + ...(this.options.description && { + description: this.options.description, + }), + ...(this.options.required && { + required: this.options.required, + }), + ...(this.options.deprecated && { + deprecated: this.options.deprecated, + }), + ...(this.options.allowEmptyValue && { + allowEmptyValue: this.options.allowEmptyValue, + }), + ...(this.options.style && { + style: this.options.style, + }), + ...(this.options.explode && { + explode: this.options.explode, + }), + ...(this.options.allowReserved && { + allowReserved: this.options.allowReserved, + }), + ...(this.options.schema && { + schema: this.options.schema.referenceObject(), + }), + } satisfies oas31.HeaderObject; + } +} diff --git a/lib/index.ts b/lib/index.ts index 74f8624..a2e1133 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,6 +6,7 @@ import { Construct } from 'constructs'; export { Construct }; export { Api } from './api.ts'; +export { Header } from './header.ts'; export { Parameter } from './parameter.ts'; export { Path } from './path.ts'; export { Reference } from './reference.ts'; diff --git a/lib/media-type.ts b/lib/media-type.ts index 38e3dc9..063f0fc 100644 --- a/lib/media-type.ts +++ b/lib/media-type.ts @@ -3,12 +3,17 @@ import type { oas31 } from 'openapi3-ts'; import { Reference } from './reference.ts'; import type { Schema } from './schema.ts'; +type ContentType = + | 'application/json' + | 'application/octet-stream' + | 'application/x-www-form-urlencoded' + | 'multipart/form-data' + | 'text/plain' + | 'image/*' + | (string & {}); + export interface MediaTypeOptions { - contentType: - | 'multipart/form-data' - | 'application/x-www-form-urlencoded' - | 'application/json' - | 'image/*'; + contentType: ContentType; schema: Schema | Reference; } diff --git a/lib/parameter.ts b/lib/parameter.ts index 373e285..6387c4f 100644 --- a/lib/parameter.ts +++ b/lib/parameter.ts @@ -3,18 +3,35 @@ import type { oas31 } from 'openapi3-ts'; import type { Api } from './api.ts'; import type { Schema } from './schema.ts'; +type StyleForIn = + TIn extends 'path' + ? 'matrix' | 'label' | 'simple' + : TIn extends 'query' + ? 'form' | 'spaceDelimited' | 'pipeDelimited' | 'deepObject' + : TIn extends 'header' + ? 'simple' + : TIn extends 'cookie' + ? 'form' + : never; + +type ParameterName< + TName extends string | number | symbol, + TIn extends 'query' | 'header' | 'path' | 'cookie', +> = TIn extends 'header' ? TName & Lowercase : TName; + interface ParameterOptionsBase< TName extends string | number | symbol, TIn extends 'query' | 'header' | 'path' | 'cookie', > { - name: TName; + name: ParameterName; in: TIn; required: boolean; description?: string; deprecated?: boolean; allowEmptyValue?: boolean; allowReserved?: boolean; - style?: 'simple'; + style?: StyleForIn; + explode?: boolean; } interface ParameterOptions< @@ -24,11 +41,6 @@ interface ParameterOptions< schema: Schema; } -// interface ParameterOptions -// extends ParameterOptionsBase { -// content: unknown; -// } - export class Parameter< TName extends string | number | symbol = '', TIn extends 'query' | 'header' | 'path' | 'cookie' = 'query', @@ -69,6 +81,7 @@ export class Parameter< allowEmptyValue: this.options.allowEmptyValue, }), ...(this.options.style && { style: this.options.style }), + ...(this.options.explode != null && { explode: this.options.explode }), ...(this.options.schema && { schema: this.options.schema.referenceObject(), }), diff --git a/lib/request-body.ts b/lib/request-body.ts index f1396a6..fdcfd5a 100644 --- a/lib/request-body.ts +++ b/lib/request-body.ts @@ -3,7 +3,7 @@ import type { oas31 } from 'openapi3-ts'; import { MediaType, type MediaTypeOptions } from './media-type.ts'; export interface RequestBodyOptions { - content: MediaType | MediaTypeOptions; + content: MediaType | MediaTypeOptions | (MediaType | MediaTypeOptions)[]; description?: string; required?: boolean; } @@ -11,7 +11,7 @@ export interface RequestBodyOptions { export class RequestBody extends Construct { private options: RequestBodyOptions; - private content: MediaType; + private contentEntries: MediaType[]; constructor(scope: Construct, id: string, options: RequestBodyOptions) { super(scope, id); @@ -20,18 +20,26 @@ export class RequestBody extends Construct { ...options, }; - this.content = - options.content instanceof MediaType - ? options.content - : new MediaType(this, id, options.content); + const items = Array.isArray(options.content) + ? options.content + : [options.content]; + + this.contentEntries = items.map((item, index) => + item instanceof MediaType + ? item + : new MediaType(this, `${id}${index}`, item), + ); } public synth(): oas31.RequestBodyObject { return { description: this.options.description || '', - content: { - [this.options.content.contentType]: this.content.synth(), - }, + content: Object.fromEntries( + this.contentEntries.map((entry) => [ + entry.contentType, + entry.synth(), + ]), + ), ...(this.options.required && { required: this.options.required }), }; } diff --git a/lib/response.ts b/lib/response.ts index d548e69..d604b19 100644 --- a/lib/response.ts +++ b/lib/response.ts @@ -4,36 +4,50 @@ import type { Header } from './header.ts'; import { MediaType, type MediaTypeOptions } from './media-type.ts'; interface ResponseOptions { - content?: MediaType | MediaTypeOptions; + content?: MediaType | MediaTypeOptions | (MediaType | MediaTypeOptions)[]; description?: string; - headers?: Header[]; + headers?: Record, Header>; } export class Response extends Construct { private options: ResponseOptions; - private content?: MediaType | undefined; + private contentEntries: MediaType[]; constructor(scope: Construct, id: string, options: ResponseOptions = {}) { super(scope, id); this.options = options; - if (options.content) { - this.content = - options.content instanceof MediaType - ? options.content - : new MediaType(this, `${id}MediaType`, options.content); - } + const items = options.content + ? Array.isArray(options.content) + ? options.content + : [options.content] + : []; + + this.contentEntries = items.map((item, index) => + item instanceof MediaType + ? item + : new MediaType(this, `${id}MediaType${index}`, item), + ); } public synth() { return { description: this.options.description || 'Successful response', - content: { - ...(this.content && { - [this.content.contentType]: this.content.synth(), - }), - }, + content: Object.fromEntries( + this.contentEntries.map((entry) => [ + entry.contentType, + entry.synth(), + ]), + ), + ...(this.options.headers && { + headers: Object.fromEntries( + Object.entries(this.options.headers).map(([name, header]) => [ + name, + header.synth(), + ]), + ), + }), } satisfies oas31.ResponseObject; } } diff --git a/lib/types.ts b/lib/types.ts index 257ea8b..79c55ea 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -12,5 +12,5 @@ export type ExtractRouteParams = string extends T export type ValidParameter = | Parameter, 'path'> | Parameter - | Parameter + | Parameter, 'header'> | Parameter;