mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
732 lines
18 KiB
TypeScript
732 lines
18 KiB
TypeScript
import { describe, expect, test } from 'vitest';
|
|
import { mergeAllOf } from '@/utils/schema/merge';
|
|
import { pickSchema } from '@/utils/schema/pick';
|
|
import { joinURL, resolveRequestData, resolveServerUrl, withBase } from '@/utils/url';
|
|
import type { RequestData } from '@/requests/types';
|
|
|
|
describe('Merge object schemas', () => {
|
|
test('Merge single object', () => {
|
|
const result = mergeAllOf({
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
test: {
|
|
type: 'string',
|
|
enum: ['one', 'two'],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"properties": {
|
|
"test": {
|
|
"enum": [
|
|
"one",
|
|
"two",
|
|
],
|
|
"type": "string",
|
|
},
|
|
},
|
|
"type": "object",
|
|
}
|
|
`);
|
|
});
|
|
|
|
test('Merge multiple objects', () => {
|
|
const result = mergeAllOf({
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
test: {
|
|
type: 'string',
|
|
enum: ['one', 'two'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
hello: {
|
|
type: 'number',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"properties": {
|
|
"hello": {
|
|
"type": "number",
|
|
},
|
|
"test": {
|
|
"enum": [
|
|
"one",
|
|
"two",
|
|
],
|
|
"type": "string",
|
|
},
|
|
},
|
|
"type": "object",
|
|
}
|
|
`);
|
|
});
|
|
|
|
test('Merge multiple objects: required', () => {
|
|
const result = mergeAllOf({
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
test: {
|
|
type: 'string',
|
|
enum: ['one', 'two'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
hello: {
|
|
type: 'number',
|
|
},
|
|
},
|
|
required: ['hello'],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"properties": {
|
|
"hello": {
|
|
"type": "number",
|
|
},
|
|
"test": {
|
|
"enum": [
|
|
"one",
|
|
"two",
|
|
],
|
|
"type": "string",
|
|
},
|
|
},
|
|
"required": [
|
|
"hello",
|
|
],
|
|
"type": "object",
|
|
}
|
|
`);
|
|
});
|
|
|
|
test('Merge multiple objects: additional properties', () => {
|
|
const result = mergeAllOf({
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
test: {
|
|
type: 'string',
|
|
enum: ['one', 'two'],
|
|
},
|
|
},
|
|
additionalProperties: true,
|
|
},
|
|
{
|
|
type: 'object',
|
|
additionalProperties: {
|
|
type: 'string',
|
|
},
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"additionalProperties": true,
|
|
"properties": {
|
|
"test": {
|
|
"enum": [
|
|
"one",
|
|
"two",
|
|
],
|
|
"type": "string",
|
|
},
|
|
},
|
|
"type": "object",
|
|
}
|
|
`);
|
|
});
|
|
|
|
test('Merge multiple objects: `allOf`', () => {
|
|
const result = mergeAllOf({
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
test: {
|
|
type: 'string',
|
|
enum: ['one', 'two'],
|
|
},
|
|
},
|
|
},
|
|
{
|
|
allOf: [
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
hello: {
|
|
type: 'number',
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
properties: {
|
|
world: {
|
|
type: 'number',
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"properties": {
|
|
"hello": {
|
|
"type": "number",
|
|
},
|
|
"test": {
|
|
"enum": [
|
|
"one",
|
|
"two",
|
|
],
|
|
"type": "string",
|
|
},
|
|
"world": {
|
|
"type": "number",
|
|
},
|
|
},
|
|
"type": "object",
|
|
}
|
|
`);
|
|
});
|
|
|
|
test('Production: `allOf` with multiple `oneOf`', () => {
|
|
const result = mergeAllOf({
|
|
type: 'object',
|
|
allOf: [
|
|
{
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
title: 'optionA',
|
|
properties: { a: { type: 'string' } },
|
|
required: ['a'],
|
|
},
|
|
{
|
|
type: 'object',
|
|
title: 'optionB',
|
|
properties: { b: { type: 'string' } },
|
|
required: ['b'],
|
|
},
|
|
],
|
|
},
|
|
{
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
title: 'optionX',
|
|
properties: { x: { type: 'number' } },
|
|
required: ['x'],
|
|
},
|
|
{
|
|
type: 'object',
|
|
title: 'optionY',
|
|
properties: { y: { type: 'number' } },
|
|
required: ['y'],
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
// Should produce cross-product: A&X, A&Y, B&X, B&Y
|
|
expect(typeof result !== 'boolean' && result.oneOf).toHaveLength(4);
|
|
});
|
|
|
|
test('Production: `allOf`', () => {
|
|
const result = mergeAllOf({
|
|
type: 'object',
|
|
allOf: [
|
|
{
|
|
properties: {
|
|
name: { type: 'string' },
|
|
},
|
|
},
|
|
{
|
|
oneOf: [
|
|
{
|
|
type: 'object',
|
|
title: 'human',
|
|
required: ['human'],
|
|
properties: {
|
|
human: {
|
|
type: 'object',
|
|
properties: {
|
|
givenName: { type: 'string' },
|
|
familyName: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
type: 'object',
|
|
title: 'machine',
|
|
required: ['machine'],
|
|
properties: {
|
|
machine: {
|
|
type: 'object',
|
|
properties: {
|
|
serialNumber: { type: 'string' },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(result).toMatchInlineSnapshot(`
|
|
{
|
|
"oneOf": [
|
|
{
|
|
"properties": {
|
|
"human": {
|
|
"properties": {
|
|
"familyName": {
|
|
"type": "string",
|
|
},
|
|
"givenName": {
|
|
"type": "string",
|
|
},
|
|
},
|
|
"type": "object",
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
},
|
|
},
|
|
"required": [
|
|
"human",
|
|
],
|
|
"title": "human",
|
|
"type": "object",
|
|
},
|
|
{
|
|
"properties": {
|
|
"machine": {
|
|
"properties": {
|
|
"serialNumber": {
|
|
"type": "string",
|
|
},
|
|
},
|
|
"type": "object",
|
|
},
|
|
"name": {
|
|
"type": "string",
|
|
},
|
|
},
|
|
"required": [
|
|
"machine",
|
|
],
|
|
"title": "machine",
|
|
"type": "object",
|
|
},
|
|
],
|
|
}
|
|
`);
|
|
});
|
|
});
|
|
|
|
describe('URL utilities', () => {
|
|
describe('joinURL', () => {
|
|
test('joins base URL with pathname', () => {
|
|
expect(joinURL('https://api.example.com', 'users')).toBe('https://api.example.com/users');
|
|
});
|
|
|
|
test('handles trailing slash in base', () => {
|
|
expect(joinURL('https://api.example.com/', 'users')).toBe('https://api.example.com/users');
|
|
});
|
|
|
|
test('handles leading slash in pathname', () => {
|
|
expect(joinURL('https://api.example.com', '/users')).toBe('https://api.example.com/users');
|
|
});
|
|
|
|
test('handles both trailing and leading slashes', () => {
|
|
expect(joinURL('https://api.example.com/', '/users')).toBe('https://api.example.com/users');
|
|
});
|
|
|
|
test('handles empty pathname', () => {
|
|
expect(joinURL('https://api.example.com', '')).toBe('https://api.example.com');
|
|
});
|
|
});
|
|
|
|
describe('withBase', () => {
|
|
test('returns absolute URL unchanged', () => {
|
|
expect(withBase('https://other.com/api', 'https://base.com')).toBe('https://other.com/api');
|
|
});
|
|
|
|
test('joins relative URL with base', () => {
|
|
expect(withBase('/api/users', 'https://base.com')).toBe('https://base.com/api/users');
|
|
});
|
|
|
|
test('joins relative URL without leading slash', () => {
|
|
expect(withBase('api/users', 'https://base.com')).toBe('https://base.com/api/users');
|
|
});
|
|
});
|
|
|
|
describe('resolveServerUrl', () => {
|
|
test('replaces single variable', () => {
|
|
expect(resolveServerUrl('https://{host}/api', { host: 'api.example.com' })).toBe(
|
|
'https://api.example.com/api',
|
|
);
|
|
});
|
|
|
|
test('replaces multiple variables', () => {
|
|
expect(
|
|
resolveServerUrl('https://{host}:{port}/api/{version}', {
|
|
host: 'api.example.com',
|
|
port: '8080',
|
|
version: 'v1',
|
|
}),
|
|
).toBe('https://api.example.com:8080/api/v1');
|
|
});
|
|
|
|
test('handles no variables', () => {
|
|
expect(resolveServerUrl('https://api.example.com', {})).toBe('https://api.example.com');
|
|
});
|
|
});
|
|
|
|
describe('resolveRequestData', () => {
|
|
test('basic path parameter substitution', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {
|
|
id: { value: '123' },
|
|
},
|
|
query: {},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/users/{id}', requestData)).toBe('/api/users/123');
|
|
});
|
|
|
|
test('multiple path parameters', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {
|
|
userId: { value: '123' },
|
|
postId: { value: '456' },
|
|
},
|
|
query: {},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/users/{userId}/posts/{postId}', requestData)).toBe(
|
|
'/api/users/123/posts/456',
|
|
);
|
|
});
|
|
|
|
test('array path parameter', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {
|
|
segments: { value: 'api/v1/users' },
|
|
},
|
|
query: {},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/{segments}', requestData)).toBe('/api/v1/users');
|
|
});
|
|
|
|
test('adds query parameters to clean path', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
limit: { values: ['10'] },
|
|
offset: { values: ['20'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/users', requestData)).toBe('/api/users?limit=10&offset=20');
|
|
});
|
|
|
|
test('handles array query parameters', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
tags: { values: ['javascript', 'typescript'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/posts', requestData)).toBe(
|
|
'/api/posts?tags=javascript&tags=typescript',
|
|
);
|
|
});
|
|
|
|
// Core test cases for paths with existing query parameters (legacy API support)
|
|
test('path with existing query parameter - verbose example', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
limit: { values: ['5'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/foo/bar?verbose=true', requestData)).toBe(
|
|
'/api/foo/bar?verbose=true&limit=5',
|
|
);
|
|
});
|
|
|
|
test('path with existing query parameter - search example', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
limit: { values: ['10'] },
|
|
sort: { values: ['date'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/location/search?name=foo', requestData)).toBe(
|
|
'/api/location/search?name=foo&limit=10&sort=date',
|
|
);
|
|
});
|
|
|
|
test('path with existing query parameters and path parameters', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {
|
|
userId: { value: '123' },
|
|
},
|
|
query: {
|
|
include: { values: ['profile'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/users/{userId}/posts?published=true', requestData)).toBe(
|
|
'/api/users/123/posts?published=true&include=profile',
|
|
);
|
|
});
|
|
|
|
test('overrides existing query parameter with new value', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
verbose: { values: ['false'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/foo/bar?verbose=true', requestData)).toBe(
|
|
'/api/foo/bar?verbose=false',
|
|
);
|
|
});
|
|
|
|
test('handles multiple existing query parameters', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
newParam: { values: ['value'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/search?q=test&type=user&active=true', requestData)).toBe(
|
|
'/api/search?q=test&type=user&active=true&newParam=value',
|
|
);
|
|
});
|
|
|
|
test('handles array parameters with existing query string', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
categories: { values: ['tech', 'science'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/articles?featured=true', requestData)).toBe(
|
|
'/api/articles?featured=true&categories=tech&categories=science',
|
|
);
|
|
});
|
|
|
|
test('replaces existing array parameter', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {
|
|
tags: { values: ['new', 'updated'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/posts?tags=old&tags=legacy', requestData)).toBe(
|
|
'/api/posts?tags=new&tags=updated',
|
|
);
|
|
});
|
|
|
|
test('handles empty query parameters object', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {},
|
|
query: {},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
expect(resolveRequestData('/api/foo/bar?verbose=true', requestData)).toBe(
|
|
'/api/foo/bar?verbose=true',
|
|
);
|
|
});
|
|
|
|
test('complex scenario with path params, existing query, and new query params', () => {
|
|
const requestData: RequestData = {
|
|
method: 'get',
|
|
path: {
|
|
orgId: { value: 'acme' },
|
|
projectId: { value: 'web-app' },
|
|
},
|
|
query: {
|
|
include: { values: ['members', 'settings'] },
|
|
format: { values: ['json'] },
|
|
debug: { values: ['true'] },
|
|
},
|
|
header: {},
|
|
cookie: {},
|
|
};
|
|
|
|
const result = resolveRequestData(
|
|
'/api/orgs/{orgId}/projects/{projectId}?version=latest&archived=false',
|
|
requestData,
|
|
);
|
|
expect(result).toBe(
|
|
'/api/orgs/acme/projects/web-app?version=latest&archived=false&include=members&include=settings&format=json&debug=true',
|
|
);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('pickSchema', () => {
|
|
test('places resolved schema at JSON Pointer path', () => {
|
|
const pet = {
|
|
type: 'object',
|
|
properties: { name: { type: 'string' } },
|
|
};
|
|
const root = {
|
|
components: {
|
|
schemas: {
|
|
Pet: pet,
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(pickSchema(root, '#/components/schemas/Pet')).toEqual({
|
|
components: {
|
|
schemas: {
|
|
Pet: pet,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('follows nested $ref and includes referenced definitions', () => {
|
|
const address = {
|
|
type: 'object',
|
|
properties: { street: { type: 'string' } },
|
|
};
|
|
const person = {
|
|
type: 'object',
|
|
properties: {
|
|
home: { $ref: '#/components/schemas/Address' },
|
|
},
|
|
};
|
|
const root = {
|
|
components: {
|
|
schemas: {
|
|
Address: address,
|
|
Person: person,
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(pickSchema(root, '#/components/schemas/Person')).toEqual({
|
|
components: {
|
|
schemas: {
|
|
Address: address,
|
|
Person: person,
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('does not revisit the same $ref', () => {
|
|
const shared = { type: 'string' };
|
|
const root = {
|
|
components: {
|
|
schemas: {
|
|
Shared: shared,
|
|
A: { $ref: '#/components/schemas/Shared' },
|
|
B: { $ref: '#/components/schemas/Shared' },
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(pickSchema(root, '#/components/schemas/A')).toEqual({
|
|
components: {
|
|
schemas: {
|
|
Shared: shared,
|
|
A: { $ref: '#/components/schemas/Shared' },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test('rejects external $ref', () => {
|
|
expect(() => pickSchema({}, 'https://example.com/schema.json#/Foo')).toThrow();
|
|
});
|
|
});
|