Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion src/spec/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ function extractOperations(spec: OpenAPISpec): NormalizedOperation[] {
httpMethod: method.toUpperCase(),
path: urlPath,
description: op.description as string | undefined,
parameters: normalizeParams(op.parameters as unknown[] | undefined, spec),
parameters: normalizeOperationParams(
methods.parameters as unknown[] | undefined,
op.parameters as unknown[] | undefined,
spec,
),
requestBody: normalizeRequestBody(op.requestBody as Record<string, unknown> | undefined, spec),
responseType: extractResponseType(op.responses as Record<string, unknown> | undefined, spec),
security: extractSecurity(op.security as Record<string, unknown>[] | undefined),
Expand All @@ -47,6 +51,23 @@ function extractOperations(spec: OpenAPISpec): NormalizedOperation[] {
return operations;
}

function normalizeOperationParams(
pathParams: unknown[] | undefined,
operationParams: unknown[] | undefined,
spec: OpenAPISpec,
): NormalizedParam[] {
const merged = new Map<string, NormalizedParam>();

for (const param of normalizeParams(
[...(pathParams ?? []), ...(operationParams ?? [])],
spec,
)) {
merged.set(`${param.in}:${param.name}`, param);
}

return [...merged.values()];
}

function normalizeParams(
params: unknown[] | undefined,
spec: OpenAPISpec,
Expand Down
27 changes: 9 additions & 18 deletions test/fixtures/petstore.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,19 @@
}
},
"/pets/{petId}": {
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The shared pet ID"
}
],
"get": {
"operationId": "getPetById",
"tags": ["Pets"],
"description": "Get a specific pet by ID",
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The ID of the pet"
}
],
"responses": {
"200": {
"description": "A pet",
Expand Down Expand Up @@ -129,15 +129,6 @@
"operationId": "deletePet",
"tags": ["Pets"],
"description": "Delete a pet",
"parameters": [
{
"name": "petId",
"in": "path",
"required": true,
"schema": { "type": "string" },
"description": "The ID of the pet to delete"
}
],
"responses": {
"204": {
"description": "Pet deleted"
Expand Down
184 changes: 184 additions & 0 deletions test/fixtures/xquik.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
{
"openapi": "3.1.0",
"info": {
"title": "Xquik API",
"version": "1.0"
},
"servers": [
{
"url": "https://xquik.com"
}
],
"paths": {
"/api/v1/x/tweets/search": {
"get": {
"operationId": "searchTweets",
"tags": ["Tweets"],
"description": "Search public posts by query.",
"parameters": [
{
"name": "q",
"in": "query",
"required": true,
"schema": {
"type": "string"
},
"description": "Search query."
},
{
"name": "queryType",
"in": "query",
"required": false,
"schema": {
"type": "string",
"enum": ["Latest", "Top"],
"default": "Latest"
},
"description": "Sort order."
},
{
"name": "cursor",
"in": "query",
"schema": {
"type": "string"
},
"description": "Pagination cursor from the previous response."
},
{
"name": "sinceTime",
"in": "query",
"schema": {
"type": "string"
},
"description": "Only return tweets after this ISO 8601 timestamp."
},
{
"name": "untilTime",
"in": "query",
"schema": {
"type": "string"
},
"description": "Only return tweets before this ISO 8601 timestamp."
},
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"default": 20,
"maximum": 200
},
"description": "Maximum number of posts to return."
},
{
"$ref": "#/components/parameters/TweetFilterFromUser"
}
],
"responses": {
"200": {
"description": "Search results.",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PaginatedTweets"
}
}
}
}
},
"security": [
{
"apiKey": []
},
{
"oauthBearer": []
},
{}
]
}
}
},
"components": {
"parameters": {
"TweetFilterFromUser": {
"name": "fromUser",
"in": "query",
"schema": {
"type": "string"
},
"description": "Filter by author username."
}
},
"securitySchemes": {
"apiKey": {
"type": "apiKey",
"in": "header",
"name": "x-api-key"
},
"oauthBearer": {
"type": "http",
"scheme": "bearer"
}
},
"schemas": {
"PaginatedTweets": {
"type": "object",
"required": ["tweets", "has_next_page", "next_cursor"],
"properties": {
"tweets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/SearchTweet"
}
},
"has_next_page": {
"type": "boolean"
},
"next_cursor": {
"type": "string"
}
}
},
"SearchTweet": {
"type": "object",
"required": [
"id",
"text",
"retweetCount",
"replyCount",
"likeCount",
"quoteCount",
"viewCount",
"bookmarkCount"
],
"properties": {
"id": {
"type": "string"
},
"text": {
"type": "string"
},
"retweetCount": {
"type": "integer"
},
"replyCount": {
"type": "integer"
},
"likeCount": {
"type": "integer"
},
"quoteCount": {
"type": "integer"
},
"viewCount": {
"type": "integer"
},
"bookmarkCount": {
"type": "integer"
}
}
}
}
}
}
25 changes: 25 additions & 0 deletions test/spec-parser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as path from 'path';
import { parseSpec } from '../src/spec/parser.js';

const PETSTORE = path.resolve(__dirname, 'fixtures', 'petstore.json');
const XQUIK = path.resolve(__dirname, 'fixtures', 'xquik.json');

describe('spec parser', () => {
it('parses all operations from petstore spec', () => {
Expand Down Expand Up @@ -33,11 +34,17 @@ describe('spec parser', () => {
it('extracts path parameters', () => {
const ops = parseSpec(PETSTORE);
const getPet = ops.find((o) => o.operationId === 'getPetById')!;
const updatePet = ops.find((o) => o.operationId === 'updatePet')!;
const deletePet = ops.find((o) => o.operationId === 'deletePet')!;

expect(getPet.parameters).toHaveLength(1);
expect(getPet.parameters[0].name).toBe('petId');
expect(getPet.parameters[0].in).toBe('path');
expect(getPet.parameters[0].required).toBe(true);
expect(getPet.parameters[0].description).toBe('The shared pet ID');
expect(deletePet.parameters[0].description).toBe('The shared pet ID');
expect(updatePet.parameters).toHaveLength(1);
expect(updatePet.parameters[0].description).toBe('The ID of the pet to update');
});

it('extracts request body with $ref resolution', () => {
Expand Down Expand Up @@ -81,4 +88,22 @@ describe('spec parser', () => {
expect(createPet.httpMethod).toBe('POST');
expect(createPet.path).toBe('/pets');
});

it('parses a real OpenAPI 3.1 search endpoint with api key auth', () => {
const ops = parseSpec(XQUIK);
const searchTweets = ops.find((o) => o.operationId === 'searchTweets')!;

expect(searchTweets.httpMethod).toBe('GET');
expect(searchTweets.path).toBe('/api/v1/x/tweets/search');
expect(searchTweets.tag).toBe('Tweets');
expect(searchTweets.parameters).toHaveLength(7);
expect(searchTweets.parameters[0].name).toBe('q');
expect(searchTweets.parameters[0].required).toBe(true);
expect(searchTweets.parameters[1].schema.default).toBe('Latest');
expect(searchTweets.parameters[5].schema.default).toBe(20);
expect(searchTweets.parameters[6].name).toBe('fromUser');
expect(searchTweets.responseType).toBe('PaginatedTweets');
expect(searchTweets.security).toContain('apiKey');
expect(searchTweets.security).toContain('oauthBearer');
});
});