From 6df49d067018470f6cdd031a2703702cab0d2e92 Mon Sep 17 00:00:00 2001 From: netanelC Date: Sun, 7 Jun 2026 18:52:09 +0300 Subject: [PATCH 1/3] feat(openapi-helpers): migrate sender to a dedicated package --- packages/openapi-helpers/package.json | 8 +- .../openapi-typed-request-sender/README.md | 28 + .../api-extractor.json} | 4 +- .../openapi-typed-request-sender/package.json | 59 ++ .../src/common/types.ts | 31 + .../src}/expect.ts | 0 .../openapi-typed-request-sender/src/index.ts | 3 + .../src}/requestSender.ts | 2 +- .../src}/types.ts | 10 +- .../tests/openapi3.yaml | 317 +++++++++ .../tests/supertest/expect.spec.ts | 4 +- .../tests/supertest/requestSender.spec.ts | 4 +- .../tests/test-with-errors.yaml | 59 ++ .../tests/types.d.ts | 621 ++++++++++++++++++ .../tsconfig.build.json | 4 + .../tsconfig.json | 5 + .../vitest.config.ts | 14 + pnpm-lock.yaml | 61 ++ 18 files changed, 1211 insertions(+), 23 deletions(-) create mode 100644 packages/openapi-typed-request-sender/README.md rename packages/{openapi-helpers/api-extractor.sender.json => openapi-typed-request-sender/api-extractor.json} (61%) create mode 100644 packages/openapi-typed-request-sender/package.json create mode 100644 packages/openapi-typed-request-sender/src/common/types.ts rename packages/{openapi-helpers/src/requestSender => openapi-typed-request-sender/src}/expect.ts (100%) create mode 100644 packages/openapi-typed-request-sender/src/index.ts rename packages/{openapi-helpers/src/requestSender => openapi-typed-request-sender/src}/requestSender.ts (99%) rename packages/{openapi-helpers/src/requestSender => openapi-typed-request-sender/src}/types.ts (91%) create mode 100644 packages/openapi-typed-request-sender/tests/openapi3.yaml rename packages/{openapi-helpers => openapi-typed-request-sender}/tests/supertest/expect.spec.ts (98%) rename packages/{openapi-helpers => openapi-typed-request-sender}/tests/supertest/requestSender.spec.ts (98%) create mode 100644 packages/openapi-typed-request-sender/tests/test-with-errors.yaml create mode 100644 packages/openapi-typed-request-sender/tests/types.d.ts create mode 100644 packages/openapi-typed-request-sender/tsconfig.build.json create mode 100644 packages/openapi-typed-request-sender/tsconfig.json create mode 100644 packages/openapi-typed-request-sender/vitest.config.ts diff --git a/packages/openapi-helpers/package.json b/packages/openapi-helpers/package.json index 3d3d7c67..561b7274 100644 --- a/packages/openapi-helpers/package.json +++ b/packages/openapi-helpers/package.json @@ -4,10 +4,6 @@ "description": "A package that provides utilities for working with openapi files", "type": "commonjs", "exports": { - "./requestSender": { - "types": "./dist/requestSender/requestSender.d.ts", - "default": "./dist/requestSender/requestSender.js" - }, "./generators": { "types": "./dist/generator/index.d.ts", "default": "./dist/generator/index.js" @@ -24,9 +20,7 @@ "prepack": "turbo run build", "check-dist": "publint && attw --profile node16 --pack .", "knip": "knip --directory ../.. --workspace packages/openapi-helpers", - "api:check": "pnpm run sender:api:check && pnpm run generators:api:check", - "sender:api": "api-extractor run --local --verbose --config ./api-extractor.sender.json", - "sender:api:check": "api-extractor run --verbose --config ./api-extractor.sender.json", + "api:check": "pnpm run generators:api:check", "generators:api": "api-extractor run --local --verbose --config ./api-extractor.generators.json", "generators:api:check": "api-extractor run --verbose --config ./api-extractor.generators.json", "generate:test:types": "pnpm run build && node dist/generator/generateTypes.mjs tests/openapi3.yaml tests/types.d.ts" diff --git a/packages/openapi-typed-request-sender/README.md b/packages/openapi-typed-request-sender/README.md new file mode 100644 index 00000000..7fced5b5 --- /dev/null +++ b/packages/openapi-typed-request-sender/README.md @@ -0,0 +1,28 @@ +# openapi-typed-request-sender + +Supertest-based testing utilities that provide full type safety and autocomplete based on OpenAPI specifications. + +## Installation + +```bash +npm install --save-dev @map-colonies/openapi-typed-request-sender supertest +``` + +## Usage + +```typescript +import { createRequestSender, expectResponseStatusFactory } from '@map-colonies/openapi-typed-request-sender'; +import type { paths, operations } from './types.d.ts'; + +const requestSender = await createRequestSender('openapi.yaml', expressApp); + +// Call API by operation name! +const response = await requestSender.getUser({ pathParams: { id: '123' } }); + +// Type-safe status assertion +const expectResponseStatus = expectResponseStatusFactory(expect); +expectResponseStatus(response, 200); + +// response.body is now correctly typed for status 200 +console.log(response.body.name); +``` diff --git a/packages/openapi-helpers/api-extractor.sender.json b/packages/openapi-typed-request-sender/api-extractor.json similarity index 61% rename from packages/openapi-helpers/api-extractor.sender.json rename to packages/openapi-typed-request-sender/api-extractor.json index 45f93c40..e2395476 100644 --- a/packages/openapi-helpers/api-extractor.sender.json +++ b/packages/openapi-typed-request-sender/api-extractor.json @@ -1,9 +1,9 @@ { "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", "extends": "../../api-extractor.json", - "mainEntryPointFilePath": "dist/requestSender/requestSender.d.ts", + "mainEntryPointFilePath": "dist/index.d.ts", "apiReport": { "enabled": true, - "reportFileName": "sender." + "reportFileName": "openapi-typed-request-sender.api.md" } } diff --git a/packages/openapi-typed-request-sender/package.json b/packages/openapi-typed-request-sender/package.json new file mode 100644 index 00000000..d1fe6a81 --- /dev/null +++ b/packages/openapi-typed-request-sender/package.json @@ -0,0 +1,59 @@ +{ + "name": "@map-colonies/openapi-typed-request-sender", + "version": "1.0.0", + "description": "Supertest-based testing utilities for OpenAPI-defined APIs", + "type": "commonjs", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "test": "vitest run", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "prebuild": "pnpm run clean", + "build": "tsc --project tsconfig.build.json", + "clean": "rimraf dist", + "prepack": "turbo run build", + "check-dist": "publint && attw --profile node16 --pack .", + "api:check": "api-extractor run --verbose --config ./api-extractor.json", + "api": "api-extractor run --local --verbose --config ./api-extractor.json" + }, + "repository": "github:MapColonies/infra-packages", + "files": [ + "dist/**/*" + ], + "publishConfig": { + "access": "public" + }, + "engines": { + "node": ">=24" + }, + "dependencies": { + "oas-normalize": "^15.0.0", + "ts-essentials": "^10.1.1" + }, + "peerDependencies": { + "@types/express": "^4.17.21", + "openapi-typescript": "^7.4.1", + "supertest": "^7.0.0" + }, + "devDependencies": { + "@map-colonies/eslint-config": "workspace:^", + "@map-colonies/tsconfig": "workspace:^", + "@types/node": "catalog:", + "@types/supertest": "catalog:", + "body-parser": "2.2.2", + "@types/body-parser": "1.19.6", + "express": "5.2.1", + "eslint": "catalog:", + "rimraf": "catalog:", + "typescript": "catalog:", + "vitest-config": "workspace:^", + "vitest": "catalog:", + "@microsoft/api-extractor": "catalog:", + "openapi-types": "12.1.3" + } +} diff --git a/packages/openapi-typed-request-sender/src/common/types.ts b/packages/openapi-typed-request-sender/src/common/types.ts new file mode 100644 index 00000000..2596788d --- /dev/null +++ b/packages/openapi-typed-request-sender/src/common/types.ts @@ -0,0 +1,31 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { WritableKeys } from 'ts-essentials'; + +export type AddIfNotNever = [U] extends [never] ? T : T & U; +export type PickWritable> = Pick>; + +export type Methods = 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head' | 'options' | 'trace'; + +export type OperationsTemplate = Record; + +export type PathsTemplate = Record< + string, + { + parameters: { + query?: any; + header?: any; + path?: any; + cookie?: any; + }; + } & { + [key in Methods]?: OperationsTemplate; + } +>; + +type HasContent = [T] extends [{ content: any }] ? T['content']['application/json'] : never; + +export type ResponseObjectToFlat = [T] extends [{ responses: any }] + ? { + [res in keyof T['responses']]: { status: res; body: HasContent }; + }[keyof T['responses']] + : never; diff --git a/packages/openapi-helpers/src/requestSender/expect.ts b/packages/openapi-typed-request-sender/src/expect.ts similarity index 100% rename from packages/openapi-helpers/src/requestSender/expect.ts rename to packages/openapi-typed-request-sender/src/expect.ts diff --git a/packages/openapi-typed-request-sender/src/index.ts b/packages/openapi-typed-request-sender/src/index.ts new file mode 100644 index 00000000..b49161dc --- /dev/null +++ b/packages/openapi-typed-request-sender/src/index.ts @@ -0,0 +1,3 @@ +export * from './requestSender.js'; +export * from './types.js'; +export * from './expect.js'; diff --git a/packages/openapi-helpers/src/requestSender/requestSender.ts b/packages/openapi-typed-request-sender/src/requestSender.ts similarity index 99% rename from packages/openapi-helpers/src/requestSender/requestSender.ts rename to packages/openapi-typed-request-sender/src/requestSender.ts index 31688e2b..ef5c6cb9 100644 --- a/packages/openapi-helpers/src/requestSender/requestSender.ts +++ b/packages/openapi-typed-request-sender/src/requestSender.ts @@ -7,7 +7,7 @@ import type express from 'express'; import oasNormalize from 'oas-normalize'; import type { OmitProperties } from 'ts-essentials'; import type { OpenAPIV3 } from 'openapi-types'; -import type { PathsTemplate, Methods, OperationsTemplate } from '../common/types'; +import type { PathsTemplate, Methods, OperationsTemplate } from './common/types'; import { expectResponseStatusFactory } from './expect'; import type { ExpectResponseStatus } from './expect'; import type { PathRequestOptions, RequestOptions, OperationsNames, RequestSender, RequestReturn, RequestSenderOptions } from './types'; diff --git a/packages/openapi-helpers/src/requestSender/types.ts b/packages/openapi-typed-request-sender/src/types.ts similarity index 91% rename from packages/openapi-helpers/src/requestSender/types.ts rename to packages/openapi-typed-request-sender/src/types.ts index c16ed320..0f162b2b 100644 --- a/packages/openapi-helpers/src/requestSender/types.ts +++ b/packages/openapi-typed-request-sender/src/types.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { OmitProperties, OptionalKeys, Prettify, RequiredKeys } from 'ts-essentials'; import type * as supertest from 'supertest'; -import type { AddIfNotNever, OperationsTemplate, PathsTemplate, PickWritable } from '../common/types'; +import type { AddIfNotNever, OperationsTemplate, PathsTemplate, PickWritable, ResponseObjectToFlat } from './common/types'; /** * Configuration options for the request sender. @@ -21,14 +21,6 @@ interface Headers { headers?: Record; } -type HasContent = [T] extends [{ content: any }] ? T['content']['application/json'] : never; - -export type ResponseObjectToFlat = [T] extends [{ responses: any }] - ? { - [res in keyof T['responses']]: { status: res; body: HasContent }; - }[keyof T['responses']] - : never; - type PathParamsObj = [T] extends [{ parameters: { path: NonNullable } }] ? { pathParams: T['parameters']['path'] } : never; type QueryParamsObj = [T] extends [{ parameters: { query?: NonNullable } }] diff --git a/packages/openapi-typed-request-sender/tests/openapi3.yaml b/packages/openapi-typed-request-sender/tests/openapi3.yaml new file mode 100644 index 00000000..972ea85b --- /dev/null +++ b/packages/openapi-typed-request-sender/tests/openapi3.yaml @@ -0,0 +1,317 @@ +openapi: 3.0.1 +info: + title: config-server + description: This is a config server that provides the means to manage all the configurations + version: 1.0.0 + license: + name: MIT + url: https://opensource.org/licenses/MIT +servers: + - url: / + - url: /api +paths: + /simple-request: + get: + operationId: simpleRequest + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-required-query-parameters: + get: + operationId: requestWithRequiredQueryParameters + parameters: + - name: name + in: query + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-optional-query-parameters: + post: + operationId: requestWithOptionalQueryParameters + parameters: + - name: name + in: query + required: false + schema: + type: string + requestBody: + content: + application/json: + schema: + type: object + properties: + propertyName: + type: string + responses: + '201': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-mixed-query-parameters: + get: + operationId: requestWithMixedQueryParameters + parameters: + - name: name + in: query + required: true + schema: + type: string + - name: age + in: query + required: false + schema: + type: number + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-path-parameters/{name}: + get: + operationId: requestWithPathParameters + parameters: + - name: name + in: path + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-empty-response: + get: + operationId: requestWithEmptyResponse + responses: + '204': + description: No Content + /request-with-headers: + get: + operationId: requestWithHeaders + parameters: + - name: name + in: header + required: true + schema: + type: string + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /request-with-all/{name}: + post: + operationId: requestWithAll + parameters: + - name: name + in: path + required: true + schema: + type: string + - name: first + in: query + required: true + schema: + type: string + - name: second + in: header + required: true + schema: + type: number + responses: + '201': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /post-request: + post: + operationId: postRequest + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '201': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /endpoint-with-multiple-methods: + parameters: + - name: test + in: query + schema: + type: string + get: + operationId: endpointWithMultipleMethodsGet + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + post: + operationId: endpointWithMultipleMethodsPost + responses: + '201': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /multiple-status-codes: + get: + operationId: multipleStatusCodes + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + number: + type: number + example: 42 + '201': + description: Created + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + type: + type: string + example: test + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /with-5xx-response: + get: + operationId: with5xxResponse + responses: + 200: + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + '5xx': + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! + /optional-request-body: + post: + operationId: optionalRequestBody + requestBody: + required: false + content: + application/json: + schema: + type: object + properties: + message: + type: string + responses: + '201': + description: OK + content: + application/json: + schema: + type: object + properties: + message: + type: string + example: Hello, World! diff --git a/packages/openapi-helpers/tests/supertest/expect.spec.ts b/packages/openapi-typed-request-sender/tests/supertest/expect.spec.ts similarity index 98% rename from packages/openapi-helpers/tests/supertest/expect.spec.ts rename to packages/openapi-typed-request-sender/tests/supertest/expect.spec.ts index abb98af3..196ffd1a 100644 --- a/packages/openapi-helpers/tests/supertest/expect.spec.ts +++ b/packages/openapi-typed-request-sender/tests/supertest/expect.spec.ts @@ -1,6 +1,6 @@ import { describe, it, expect, expectTypeOf, vi } from 'vitest'; -import { expectResponseStatusFactory } from '../../src/requestSender/expect'; -import type { ExpectResponseStatus } from '../../src/requestSender/expect'; +import { expectResponseStatusFactory } from '../../src/expect'; +import type { ExpectResponseStatus } from '../../src/expect'; describe('expectResponseStatusFactory', () => { describe('logic tests', () => { diff --git a/packages/openapi-helpers/tests/supertest/requestSender.spec.ts b/packages/openapi-typed-request-sender/tests/supertest/requestSender.spec.ts similarity index 98% rename from packages/openapi-helpers/tests/supertest/requestSender.spec.ts rename to packages/openapi-typed-request-sender/tests/supertest/requestSender.spec.ts index 99344e2f..4d64a3da 100644 --- a/packages/openapi-helpers/tests/supertest/requestSender.spec.ts +++ b/packages/openapi-typed-request-sender/tests/supertest/requestSender.spec.ts @@ -1,8 +1,8 @@ import { describe, beforeEach, it, expect, expectTypeOf } from 'vitest'; import express from 'express'; import bodyParser from 'body-parser'; -import type { RequestSender } from '../../src/requestSender/types'; -import { createRequestSender } from '../../src/requestSender/requestSender'; +import type { RequestSender } from '../../src/types'; +import { createRequestSender } from '../../src/requestSender'; import type { paths, operations } from '../types'; describe('requestSender', () => { diff --git a/packages/openapi-typed-request-sender/tests/test-with-errors.yaml b/packages/openapi-typed-request-sender/tests/test-with-errors.yaml new file mode 100644 index 00000000..bab5c66c --- /dev/null +++ b/packages/openapi-typed-request-sender/tests/test-with-errors.yaml @@ -0,0 +1,59 @@ +openapi: 3.0.0 +info: + title: Test API with Errors + version: 1.0.0 +paths: + /test: + get: + operationId: testOperation + responses: + '200': + description: Success + content: + application/json: + schema: + type: object + properties: + message: + type: string + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - INVALID_INPUT + - MISSING_PARAMETER + message: + type: string + '404': + description: Not Found + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - RESOURCE_NOT_FOUND + message: + type: string + '500': + description: Internal Server Error + content: + application/json: + schema: + type: object + properties: + code: + type: string + enum: + - INTERNAL_ERROR + - DATABASE_ERROR + message: + type: string diff --git a/packages/openapi-typed-request-sender/tests/types.d.ts b/packages/openapi-typed-request-sender/tests/types.d.ts new file mode 100644 index 00000000..abd3e852 --- /dev/null +++ b/packages/openapi-typed-request-sender/tests/types.d.ts @@ -0,0 +1,621 @@ +/* eslint-disable */ +export type paths = { + '/simple-request': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['simpleRequest']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-required-query-parameters': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['requestWithRequiredQueryParameters']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-optional-query-parameters': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['requestWithOptionalQueryParameters']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-mixed-query-parameters': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['requestWithMixedQueryParameters']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-path-parameters/{name}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['requestWithPathParameters']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-empty-response': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['requestWithEmptyResponse']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-headers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['requestWithHeaders']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/request-with-all/{name}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['requestWithAll']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/post-request': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['postRequest']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/endpoint-with-multiple-methods': { + parameters: { + query?: { + test?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['endpointWithMultipleMethodsGet']; + put?: never; + post: operations['endpointWithMultipleMethodsPost']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/multiple-status-codes': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['multipleStatusCodes']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/with-5xx-response': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['with5xxResponse']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/optional-request-body': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post: operations['optionalRequestBody']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +}; +export type webhooks = Record; +export type components = { + schemas: never; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +}; +export type $defs = Record; +export interface operations { + simpleRequest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithRequiredQueryParameters: { + parameters: { + query: { + name: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithOptionalQueryParameters: { + parameters: { + query?: { + name?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + propertyName?: string; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithMixedQueryParameters: { + parameters: { + query: { + name: string; + age?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithPathParameters: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithEmptyResponse: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + requestWithHeaders: { + parameters: { + query?: never; + header: { + name: string; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + requestWithAll: { + parameters: { + query: { + first: string; + }; + header: { + second: number; + }; + path: { + name: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + postRequest: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': { + message?: string; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + endpointWithMultipleMethodsGet: { + parameters: { + query?: { + test?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + endpointWithMultipleMethodsPost: { + parameters: { + query?: { + test?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + multipleStatusCodes: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + /** @example 42 */ + number?: number; + }; + }; + }; + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + /** @example test */ + type?: string; + }; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + with5xxResponse: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + /** @description Internal Server Error */ + '5xx': { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; + optionalRequestBody: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + message?: string; + }; + }; + }; + responses: { + /** @description OK */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': { + /** @example Hello, World! */ + message?: string; + }; + }; + }; + }; + }; +} diff --git a/packages/openapi-typed-request-sender/tsconfig.build.json b/packages/openapi-typed-request-sender/tsconfig.build.json new file mode 100644 index 00000000..f37867a7 --- /dev/null +++ b/packages/openapi-typed-request-sender/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["tests/**"] +} diff --git a/packages/openapi-typed-request-sender/tsconfig.json b/packages/openapi-typed-request-sender/tsconfig.json new file mode 100644 index 00000000..317c576e --- /dev/null +++ b/packages/openapi-typed-request-sender/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "@map-colonies/tsconfig/tsconfig-library", + "include": ["src", "tests"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/openapi-typed-request-sender/vitest.config.ts b/packages/openapi-typed-request-sender/vitest.config.ts new file mode 100644 index 00000000..d8c82e93 --- /dev/null +++ b/packages/openapi-typed-request-sender/vitest.config.ts @@ -0,0 +1,14 @@ +import { mergeConfig, defineProject } from 'vitest/config'; +import sharedConfig from 'vitest-config'; + +export default mergeConfig( + sharedConfig, + defineProject({ + test: { + root: __dirname, + typecheck: { + enabled: true, + }, + }, + }) +); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 32251dcd..fb312634 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -661,6 +661,67 @@ importers: specifier: workspace:^ version: link:../../internal/vitest-config + packages/openapi-typed-request-sender: + dependencies: + '@types/express': + specifier: ^4.17.21 + version: 4.17.25 + oas-normalize: + specifier: ^15.0.0 + version: 15.7.0 + openapi-typescript: + specifier: ^7.4.1 + version: 7.10.1(typescript@5.9.3) + supertest: + specifier: ^7.0.0 + version: 7.2.2 + ts-essentials: + specifier: ^10.1.1 + version: 10.1.1(typescript@5.9.3) + devDependencies: + '@map-colonies/eslint-config': + specifier: workspace:^ + version: link:../eslint-config + '@map-colonies/tsconfig': + specifier: workspace:^ + version: link:../typescript-config + '@microsoft/api-extractor': + specifier: 'catalog:' + version: 7.55.2(@types/node@24.10.9) + '@types/body-parser': + specifier: 1.19.6 + version: 1.19.6 + '@types/node': + specifier: 'catalog:' + version: 24.10.9 + '@types/supertest': + specifier: 'catalog:' + version: 6.0.3 + body-parser: + specifier: 2.2.2 + version: 2.2.2 + eslint: + specifier: 'catalog:' + version: 9.39.1(jiti@2.6.1) + express: + specifier: 5.2.1 + version: 5.2.1 + openapi-types: + specifier: 12.1.3 + version: 12.1.3 + rimraf: + specifier: 'catalog:' + version: 6.1.2 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@24.10.9)(@vitest/ui@4.0.18)(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(yaml@2.8.2)) + vitest-config: + specifier: workspace:^ + version: link:../../internal/vitest-config + packages/prettier-config: devDependencies: publint: From 1923c4253688950a2285ff8fb1f4d970b09fc351 Mon Sep 17 00:00:00 2001 From: netanelC Date: Sun, 7 Jun 2026 19:13:30 +0300 Subject: [PATCH 2/3] style(openapi-helpers): fix lints --- packages/openapi-typed-request-sender/eslint.config.mjs | 4 ++++ packages/openapi-typed-request-sender/src/common/types.ts | 4 ++-- packages/openapi-typed-request-sender/src/requestSender.ts | 4 ---- 3 files changed, 6 insertions(+), 6 deletions(-) create mode 100644 packages/openapi-typed-request-sender/eslint.config.mjs diff --git a/packages/openapi-typed-request-sender/eslint.config.mjs b/packages/openapi-typed-request-sender/eslint.config.mjs new file mode 100644 index 00000000..d65b7eef --- /dev/null +++ b/packages/openapi-typed-request-sender/eslint.config.mjs @@ -0,0 +1,4 @@ +import baseConfig from '@map-colonies/eslint-config/ts-base'; +import { defineConfig } from 'eslint/config'; + +export default defineConfig(baseConfig, { ignores: ['vitest.config.ts'] }); diff --git a/packages/openapi-typed-request-sender/src/common/types.ts b/packages/openapi-typed-request-sender/src/common/types.ts index 2596788d..1ee0d1eb 100644 --- a/packages/openapi-typed-request-sender/src/common/types.ts +++ b/packages/openapi-typed-request-sender/src/common/types.ts @@ -1,6 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import type { WritableKeys } from 'ts-essentials'; +type HasContent = [T] extends [{ content: any }] ? T['content']['application/json'] : never; + export type AddIfNotNever = [U] extends [never] ? T : T & U; export type PickWritable> = Pick>; @@ -22,8 +24,6 @@ export type PathsTemplate = Record< } >; -type HasContent = [T] extends [{ content: any }] ? T['content']['application/json'] : never; - export type ResponseObjectToFlat = [T] extends [{ responses: any }] ? { [res in keyof T['responses']]: { status: res; body: HasContent }; diff --git a/packages/openapi-typed-request-sender/src/requestSender.ts b/packages/openapi-typed-request-sender/src/requestSender.ts index ef5c6cb9..50c46be5 100644 --- a/packages/openapi-typed-request-sender/src/requestSender.ts +++ b/packages/openapi-typed-request-sender/src/requestSender.ts @@ -8,8 +8,6 @@ import oasNormalize from 'oas-normalize'; import type { OmitProperties } from 'ts-essentials'; import type { OpenAPIV3 } from 'openapi-types'; import type { PathsTemplate, Methods, OperationsTemplate } from './common/types'; -import { expectResponseStatusFactory } from './expect'; -import type { ExpectResponseStatus } from './expect'; import type { PathRequestOptions, RequestOptions, OperationsNames, RequestSender, RequestReturn, RequestSenderOptions } from './types'; function sendRequest< @@ -96,8 +94,6 @@ function getOperationsPathAndMethod, { path: keyof Paths; method: Methods }>; } -export { RequestSender, expectResponseStatusFactory, ExpectResponseStatus }; - /** * Creates a request sender object that can be used to send fake HTTP requests using supertest based on an OpenAPI specification. * The openapi types should be generated using the openapi-typescript package. From 8b3e412045e24e519eaff7e76d9afffb1be52ae5 Mon Sep 17 00:00:00 2001 From: netanelC Date: Tue, 9 Jun 2026 08:28:51 +0300 Subject: [PATCH 3/3] chore(openapi-typed-request-sender): release please Co-authored-by: Copilot --- .release-please-manifest.json | 1 + packages/openapi-typed-request-sender/package.json | 2 +- release-please-config.json | 4 ++++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.release-please-manifest.json b/.release-please-manifest.json index afa92bf9..d27cd598 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -11,6 +11,7 @@ "packages/openapi-express-viewer": "4.1.0", "packages/openapi-helpers": "5.1.0", "packages/openapi-typed-request-handler": "0.1.0", + "packages/openapi-typed-request-sender": "0.1.0", "packages/semantic-conventions": "1.0.0", "packages/tracing": "1.0.0", "packages/tracing-utils": "1.0.0", diff --git a/packages/openapi-typed-request-sender/package.json b/packages/openapi-typed-request-sender/package.json index d1fe6a81..46275d4c 100644 --- a/packages/openapi-typed-request-sender/package.json +++ b/packages/openapi-typed-request-sender/package.json @@ -1,6 +1,6 @@ { "name": "@map-colonies/openapi-typed-request-sender", - "version": "1.0.0", + "version": "0.1.0", "description": "Supertest-based testing utilities for OpenAPI-defined APIs", "type": "commonjs", "exports": { diff --git a/release-please-config.json b/release-please-config.json index 3d22d995..b1890fc1 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -106,6 +106,10 @@ "package-name": "@map-colonies/openapi-typed-request-handler", "release-type": "node" }, + "packages/openapi-typed-request-sender": { + "package-name": "@map-colonies/openapi-typed-request-sender", + "release-type": "node" + }, "packages/semantic-conventions": { "package-name": "@map-colonies/semantic-conventions", "release-type": "node"