From 2af3e37f3d1e1270bc2bb419adf79ad15e0484bf Mon Sep 17 00:00:00 2001 From: Max Holman Date: Thu, 9 Apr 2026 15:18:37 +0700 Subject: [PATCH] feat!: support response headers, type-safe parameter styles, and multi-content-type responses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the Header construct for OpenAPI 3.1 response headers with components/headers registration and $ref support. Enforce lowercase header names at the type level for HTTP/2 compliance (RFC 7540 ยง8.1.2). Constrain parameter style values per location (path/query/header/cookie) matching the OAS 3.1 style table, and add the missing explode field. Widen MediaType contentType to accept any MIME string while preserving autocomplete for common types. Allow Response and RequestBody to accept arrays of content types for endpoints serving multiple representations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../__snapshots__/json-schema.test.ts.snap | 9 + .../__snapshots__/openapi-schema.test.ts.snap | 287 ++++++++++++++++++ __tests__/fixtures/apis/example.ts | 73 +++++ __tests__/parameter-types.test-d.ts | 7 +- lib/api.ts | 6 + lib/header.ts | 65 +++- lib/index.ts | 1 + lib/media-type.ts | 15 +- lib/parameter.ts | 27 +- lib/request-body.ts | 26 +- lib/response.ts | 42 ++- lib/types.ts | 2 +- 12 files changed, 522 insertions(+), 38 deletions(-) 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;