OpenAPI: Support legacy API path specs (#2246)

This commit is contained in:
Noel Madali
2025-08-27 23:00:34 +08:00
committed by GitHub
parent dfdd9bed42
commit 585e1b20c2
3 changed files with 349 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-openapi': minor
---
Enhance resolveRequestData to support query parameters in path (legacy OpenAPI behavior)
+14 -3
View File
@@ -35,6 +35,7 @@ export function resolveRequestData(
pathname: string,
{ path, query }: RequestData,
): string {
// First, resolve path parameters in the pathname
for (const key in path) {
const param = path[key];
@@ -45,18 +46,28 @@ export function resolveRequestData(
}
}
const searchParams = new URLSearchParams();
// Check if pathname already contains query parameters (legacy API support)
const [pathPart, existingQueryString] = pathname.split('?', 2);
// Parse existing query parameters from the pathname if they exist
const searchParams = new URLSearchParams(existingQueryString || '');
// Add new query parameters from the RequestData
for (const key in query) {
const param = query[key];
if (Array.isArray(param.value)) {
// Remove existing parameter first to avoid duplicates
searchParams.delete(key);
for (const item of param.value) {
searchParams.append(key, item);
}
} else {
searchParams.append(key, param.value);
// Set (replace if exists) the parameter value
searchParams.set(key, param.value);
}
}
return searchParams.size > 0 ? `${pathname}?${searchParams}` : pathname;
// Return the reconstructed URL
return searchParams.size > 0 ? `${pathPart}?${searchParams}` : pathPart;
}
+330
View File
@@ -1,5 +1,12 @@
import { describe, expect, test } from 'vitest';
import { combineSchema } from '@/utils/combine-schema';
import {
joinURL,
withBase,
resolveServerUrl,
resolveRequestData,
} from '@/utils/url';
import type { RequestData } from '@/requests/_shared';
describe('Merge object schemas', () => {
test('Merge single object', () => {
@@ -207,3 +214,326 @@ describe('Merge object schemas', () => {
`);
});
});
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: { value: '10' },
offset: { value: '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: { value: ['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: { value: '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: { value: '10' },
sort: { value: '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: { value: '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: { value: '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: { value: '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: { value: ['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: { value: ['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: { value: ['members', 'settings'] },
format: { value: 'json' },
debug: { value: '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',
);
});
});
});