UNPKG

@furystack/rest

Version:
824 lines (731 loc) 26.2 kB
import { describe, it, expectTypeOf } from 'vitest' import type { OpenApiDocument } from './openapi-document.js' import type { ConvertOpenApiPath, JsonSchemaToType, OpenApiToRestApi } from './openapi-to-rest-api.js' import type { RestApi } from './rest-api.js' describe('ConvertOpenApiPath', () => { it('Should convert single {param} to :param', () => { expectTypeOf<ConvertOpenApiPath<'/users/{id}'>>().toEqualTypeOf<'/users/:id'>() }) it('Should convert multiple params', () => { expectTypeOf<ConvertOpenApiPath<'/users/{userId}/posts/{postId}'>>().toEqualTypeOf<'/users/:userId/posts/:postId'>() }) it('Should pass through paths without params', () => { expectTypeOf<ConvertOpenApiPath<'/users'>>().toEqualTypeOf<'/users'>() }) it('Should handle root path', () => { expectTypeOf<ConvertOpenApiPath<'/'>>().toEqualTypeOf<'/'>() }) it('Should handle param at the end', () => { expectTypeOf<ConvertOpenApiPath<'/{version}'>>().toEqualTypeOf<'/:version'>() }) it('Should handle adjacent segments with params', () => { expectTypeOf<ConvertOpenApiPath<'/{a}/{b}'>>().toEqualTypeOf<'/:a/:b'>() }) }) describe('JsonSchemaToType', () => { describe('Primitive types', () => { it('Should map string', () => { expectTypeOf<JsonSchemaToType<{ type: 'string' }>>().toEqualTypeOf<string>() }) it('Should map number', () => { expectTypeOf<JsonSchemaToType<{ type: 'number' }>>().toEqualTypeOf<number>() }) it('Should map integer to number', () => { expectTypeOf<JsonSchemaToType<{ type: 'integer' }>>().toEqualTypeOf<number>() }) it('Should map boolean', () => { expectTypeOf<JsonSchemaToType<{ type: 'boolean' }>>().toEqualTypeOf<boolean>() }) it('Should map null', () => { expectTypeOf<JsonSchemaToType<{ type: 'null' }>>().toEqualTypeOf<null>() }) }) describe('String enums', () => { it('Should map string enum to union', () => { type Schema = { type: 'string'; enum: readonly ['a', 'b', 'c'] } expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<'a' | 'b' | 'c'>() }) it('Should map single-value enum', () => { type Schema = { type: 'string'; enum: readonly ['only'] } expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<'only'>() }) }) describe('Arrays', () => { it('Should map string array', () => { expectTypeOf<JsonSchemaToType<{ type: 'array'; items: { type: 'string' } }>>().toEqualTypeOf<string[]>() }) it('Should map number array', () => { expectTypeOf<JsonSchemaToType<{ type: 'array'; items: { type: 'number' } }>>().toEqualTypeOf<number[]>() }) it('Should map nested object array', () => { type Schema = { type: 'array'; items: { type: 'object'; properties: { id: { type: 'string' } } } } expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<Array<{ id?: string }>>() }) }) describe('Objects', () => { it('Should map object with all-optional properties', () => { type Schema = { type: 'object'; properties: { name: { type: 'string' }; age: { type: 'number' } } } expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<{ name?: string; age?: number }>() }) it('Should map object with required properties', () => { type Schema = { type: 'object' properties: { name: { type: 'string' }; age: { type: 'number' } } required: readonly ['name'] } expectTypeOf<JsonSchemaToType<Schema>>().toEqualTypeOf<{ name: string } & { age?: number }>() }) it('Should map object with all required properties', () => { type Schema = { type: 'object' properties: { name: { type: 'string' }; age: { type: 'number' } } required: readonly ['name', 'age'] } type Result = JsonSchemaToType<Schema> expectTypeOf<Result>().toHaveProperty('name') expectTypeOf<Result['name']>().toEqualTypeOf<string>() expectTypeOf<Result['age']>().toEqualTypeOf<number>() }) it('Should map object without properties to Record<string, unknown>', () => { expectTypeOf<JsonSchemaToType<{ type: 'object' }>>().toEqualTypeOf<Record<string, unknown>>() }) }) describe('Fallback', () => { it('Should return unknown for unrecognized schemas', () => { expectTypeOf<JsonSchemaToType<{ description: 'something' }>>().toEqualTypeOf<unknown>() }) it('Should return unknown for empty object', () => { expectTypeOf<JsonSchemaToType<Record<string, never>>>().toEqualTypeOf<unknown>() }) }) }) describe('OpenApiToRestApi', () => { describe('HTTP methods', () => { it('Should extract GET endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toExtend<RestApi>() expectTypeOf<Api>().toHaveProperty('GET') }) it('Should extract POST endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { post: { responses: { '201': { description: 'Created' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('POST') }) it('Should extract PUT endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items/{id}': { put: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('PUT') }) it('Should extract DELETE endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items/{id}': { delete: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('DELETE') }) it('Should extract PATCH endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items/{id}': { patch: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('PATCH') }) it('Should extract HEAD endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { head: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('HEAD') }) it('Should extract OPTIONS endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { options: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('OPTIONS') }) it('Should extract TRACE endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { trace: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('TRACE') }) it('Should handle multiple methods on the same path', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { responses: { '200': { description: 'OK' } } }, post: { responses: { '201': { description: 'Created' } } }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toHaveProperty('GET') expectTypeOf<Api>().toHaveProperty('POST') }) it('Should only include methods that have endpoints', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().not.toHaveProperty('POST') expectTypeOf<Api>().not.toHaveProperty('PUT') expectTypeOf<Api>().not.toHaveProperty('DELETE') expectTypeOf<Api>().not.toHaveProperty('PATCH') }) }) describe('Response types', () => { it('Should extract typed 200 response', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: 'array', items: { type: 'string' } } } }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/users']['result']>().toExtend<string[]>() }) it('Should extract typed 201 response', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { post: { responses: { '201': { description: 'Created', content: { 'application/json': { schema: { type: 'object', properties: { id: { type: 'string' } } }, }, }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['POST']['/users']['result']>().toEqualTypeOf<{ readonly id?: string }>() }) it('Should return unknown for responses without schemas', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/health': { get: { responses: { '200': { description: 'OK' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/health']['result']>().toEqualTypeOf<unknown>() }) it('Should return unknown for non-JSON content types', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/file': { get: { responses: { '200': { description: 'OK', content: { 'application/octet-stream': { schema: { type: 'string' } } }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/file']['result']>().toEqualTypeOf<unknown>() }) }) describe('Path parameters', () => { it('Should extract single path parameter', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users/{id}': { get: { responses: { '200': { description: 'OK' } } }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Url = Api['GET']['/users/:id']['url'] expectTypeOf<Url>().toHaveProperty('id') expectTypeOf<Url['id']>().toBeString() }) it('Should extract multiple path parameters', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users/{userId}/posts/{postId}': { get: { responses: { '200': { description: 'OK' } } }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Url = Api['GET']['/users/:userId/posts/:postId']['url'] expectTypeOf<Url>().toHaveProperty('userId') expectTypeOf<Url>().toHaveProperty('postId') expectTypeOf<Url['userId']>().toBeString() expectTypeOf<Url['postId']>().toBeString() }) it('Should not have url property for paths without params', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { get: { responses: { '200': { description: 'OK' } } }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Endpoint = Api['GET']['/users'] expectTypeOf<Endpoint>().not.toHaveProperty('url') }) }) describe('Request body', () => { it('Should extract JSON request body', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { post: { requestBody: { content: { 'application/json': { schema: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string' } }, required: ['name', 'email'] as const, }, }, }, }, responses: { '201': { description: 'Created' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['POST']['/users']['body']>().toExtend<{ name: string email: string }>() }) it('Should not have body property when no request body', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { responses: { '200': { description: 'OK' } } }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Endpoint = Api['GET']['/items'] expectTypeOf<Endpoint>().not.toHaveProperty('body') }) }) describe('Query parameters', () => { it('Should extract typed query parameters', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/search': { get: { parameters: [ { name: 'q', in: 'query', schema: { type: 'string' } }, { name: 'limit', in: 'query', schema: { type: 'integer' } }, ], responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/search']['query']>().toEqualTypeOf<{ q: string } & { limit: number }>() }) it('Should default to string for query params without schema', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/search': { get: { parameters: [{ name: 'q', in: 'query' }], responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/search']['query']>().toEqualTypeOf<{ q: string }>() }) it('Should not mix path params into query', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users/{id}': { get: { parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string' } }, { name: 'fields', in: 'query', schema: { type: 'string' } }, ], responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Query = Api['GET']['/users/:id']['query'] expectTypeOf<Query>().toHaveProperty('fields') expectTypeOf<Query['fields']>().toBeString() type Url = Api['GET']['/users/:id']['url'] expectTypeOf<Url>().toHaveProperty('id') expectTypeOf<Url['id']>().toBeString() }) it('Should not have query property when no query parameters', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Endpoint = Api['GET']['/items'] expectTypeOf<Endpoint>().not.toHaveProperty('query') }) }) describe('Edge cases', () => { it('Should handle document with no paths', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toExtend<RestApi>() }) it('Should handle document with empty paths', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: {}, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api>().toExtend<RestApi>() }) it('Should handle multiple paths', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/a': { get: { responses: { '200': { description: 'OK' } } } }, '/b': { get: { responses: { '200': { description: 'OK' } } } }, '/c': { post: { responses: { '201': { description: 'Created' } } } }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']>().toHaveProperty('/a') expectTypeOf<Api['GET']>().toHaveProperty('/b') expectTypeOf<Api['POST']>().toHaveProperty('/c') }) }) describe('$ref resolution', () => { it('Should resolve $ref in response schema', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { $ref: '#/components/schemas/User' } }, }, }, }, }, }, }, components: { schemas: { User: { type: 'object', properties: { id: { type: 'string' }, name: { type: 'string' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Result = Api['GET']['/users']['result'] expectTypeOf<Result>().toHaveProperty('id') expectTypeOf<Result>().toHaveProperty('name') }) it('Should resolve $ref in request body schema', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/users': { post: { requestBody: { content: { 'application/json': { schema: { $ref: '#/components/schemas/CreateUser' } }, }, }, responses: { '201': { description: 'Created' } }, }, }, }, components: { schemas: { CreateUser: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'], }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Body = Api['POST']['/users']['body'] expectTypeOf<Body>().toHaveProperty('name') }) }) describe('Schema composition', () => { it('Should handle oneOf as union type', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/shape': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { oneOf: [ { type: 'object', properties: { radius: { type: 'number' } } }, { type: 'object', properties: { width: { type: 'number' } } }, ], }, }, }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Result = Api['GET']['/shape']['result'] expectTypeOf<{ radius?: number }>().toExtend<Result>() expectTypeOf<{ width?: number }>().toExtend<Result>() }) it('Should handle allOf as intersection type', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/item': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { allOf: [ { type: 'object', properties: { id: { type: 'string' } } }, { type: 'object', properties: { name: { type: 'string' } } }, ], }, }, }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> type Result = Api['GET']['/item']['result'] expectTypeOf<Result>().toHaveProperty('id') expectTypeOf<Result>().toHaveProperty('name') }) it('Should handle const values', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/status': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { const: 'active' } }, }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/status']['result']>().toEqualTypeOf<'active'>() }) it('Should handle nullable types (3.1 tuple style)', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/item': { get: { responses: { '200': { description: 'OK', content: { 'application/json': { schema: { type: ['string', 'null'] }, }, }, }, }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/item']['result']>().toEqualTypeOf<string | null>() }) }) describe('Metadata extraction', () => { it('Should extract tags at type level', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { tags: ['store'], responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/items']>().toHaveProperty('tags') }) it('Should extract deprecated flag at type level', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/old': { get: { deprecated: true, responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/old']>().toHaveProperty('deprecated') }) it('Should extract summary and description at type level', () => { const doc = { openapi: '3.1.0', info: { title: 'Test', version: '1.0.0' }, paths: { '/items': { get: { summary: 'List items', description: 'Returns all items', responses: { '200': { description: 'OK' } }, }, }, }, } as const satisfies OpenApiDocument type Api = OpenApiToRestApi<typeof doc> expectTypeOf<Api['GET']['/items']>().toHaveProperty('summary') expectTypeOf<Api['GET']['/items']>().toHaveProperty('description') }) }) })