@furystack/rest
Version:
Generic REST package
824 lines (731 loc) • 26.2 kB
text/typescript
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')
})
})
})