diff --git a/src/spec/parser.ts b/src/spec/parser.ts index 02e5cea..fcd44ae 100644 --- a/src/spec/parser.ts +++ b/src/spec/parser.ts @@ -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 | undefined, spec), responseType: extractResponseType(op.responses as Record | undefined, spec), security: extractSecurity(op.security as Record[] | undefined), @@ -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(); + + 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, diff --git a/test/fixtures/petstore.json b/test/fixtures/petstore.json index 7cde02d..317ff58 100644 --- a/test/fixtures/petstore.json +++ b/test/fixtures/petstore.json @@ -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", @@ -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" diff --git a/test/fixtures/xquik.json b/test/fixtures/xquik.json new file mode 100644 index 0000000..158d6bc --- /dev/null +++ b/test/fixtures/xquik.json @@ -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" + } + } + } + } + } +} diff --git a/test/spec-parser.test.ts b/test/spec-parser.test.ts index e37cd3a..99fcb4c 100644 --- a/test/spec-parser.test.ts +++ b/test/spec-parser.test.ts @@ -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', () => { @@ -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', () => { @@ -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'); + }); });