From 3ab1aa37b6789089e4686685c30ec27d40bb14da Mon Sep 17 00:00:00 2001 From: Pawel <41388251+kainpets@users.noreply.github.com> Date: Tue, 6 Aug 2024 23:45:55 +0200 Subject: [PATCH] feat: Detect preferred and semantic method (#39) * feat: Detect preferred and semantic method * ci: Format code * implement semantic and preferred methods helper functions * unit test for get and post methods * add unit tests for complex methods combinations * minor fixes * test complex parameters preferred method detection and refactor * format * resolve merge conflict * ci: Generate code * resolve conflicts * refactor tests --------- Co-authored-by: Seam Bot --- src/lib/blueprint.test.ts | 272 +++++++++++++++++++++++++++++++++++++- src/lib/blueprint.ts | 105 +++++++++++---- src/lib/openapi.ts | 2 +- 3 files changed, 347 insertions(+), 32 deletions(-) diff --git a/src/lib/blueprint.test.ts b/src/lib/blueprint.test.ts index 9264f70..7c51fb1 100644 --- a/src/lib/blueprint.test.ts +++ b/src/lib/blueprint.test.ts @@ -1,7 +1,12 @@ import test from 'ava' -import { createProperties } from 'lib/blueprint.js' -import type { OpenapiSchema } from 'lib/openapi.js' +import { + createProperties, + getPreferredMethod, + getSemanticMethod, + type Method, +} from 'lib/blueprint.js' +import type { OpenapiOperation, OpenapiSchema } from 'lib/openapi.js' test('createProperties: assigns appropriate default values', (t) => { const minimalProperties = { @@ -71,3 +76,266 @@ test('createProperties: uses provided values', (t) => { 'isUndocumented should be true when x-undocumented is provided', ) }) + +const postEndpoint: OpenapiOperation = { + summary: '/users/create', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + properties: { + user: { + $ref: '#/components/schemas/user', + type: 'object', + }, + ok: { + type: 'boolean', + }, + }, + required: ['user', 'ok'], + }, + }, + }, + }, + }, + operationId: 'usersCreatePost', +} + +test('getSemanticMethod: post only', (t) => { + const postOnlyMethods: Method[] = ['POST'] + t.is( + getSemanticMethod(postOnlyMethods), + 'POST', + 'Semantic method should be POST when only POST is available', + ) +}) + +test('getPreferredMethod: post only', (t) => { + const postOnlyMethods: Method[] = ['POST'] + t.is( + getPreferredMethod(postOnlyMethods, 'POST', postEndpoint), + 'POST', + 'Preferred method should be POST when only POST is available', + ) +}) + +const getPostEndpoint: OpenapiOperation = { + summary: '/workspaces/get', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + workspace: { + type: 'object', + $ref: '#/components/schemas/workspace', + }, + ok: { + type: 'boolean', + }, + }, + required: ['workspace', 'ok'], + }, + }, + }, + }, + }, + operationId: 'workspacesGetPost', +} + +test('getSemanticMethod: get and post', (t) => { + const bothMethods: Method[] = ['GET', 'POST'] + t.is( + getSemanticMethod(bothMethods), + 'GET', + 'Semantic method should be GET when both GET and POST are available', + ) +}) + +test('getPreferredMethod: get and post without complex parameters', (t) => { + const bothMethods: Method[] = ['GET', 'POST'] + t.is( + getPreferredMethod(bothMethods, 'GET', getPostEndpoint), + 'GET', + 'Preferred method should be GET when both methods are available and no complex parameters', + ) +}) + +const getPostComplexParamsEndpoint: OpenapiOperation = { + ...getPostEndpoint, + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + complexParam: { type: 'object' }, + }, + }, + }, + }, + }, +} + +test('getPreferredMethod: get and post with complex parameters', (t) => { + const bothMethods: Method[] = ['GET', 'POST'] + t.is( + getPreferredMethod(bothMethods, 'GET', getPostComplexParamsEndpoint), + 'POST', + 'Preferred method should be POST when both GET and POST are available and complex parameters are present', + ) +}) + +const patchPostEndpoint: OpenapiOperation = { + summary: '/user_identities/update', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + properties: { + ok: { + type: 'boolean', + }, + }, + required: ['ok'], + }, + }, + }, + }, + '400': { + description: 'Bad Request', + }, + '401': { + description: 'Unauthorized', + }, + }, + security: [ + { pat_with_workspace: [] }, + { console_session: [] }, + { api_key: [] }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + user_identity_id: { + type: 'string', + format: 'uuid', + }, + user_identity_key: { + type: 'string', + }, + email_address: { + type: 'string', + format: 'email', + }, + phone_number: { + type: 'string', + }, + full_name: { + type: 'string', + }, + }, + required: ['user_identity_id'], + }, + }, + }, + }, + tags: ['/user_identities'], + operationId: 'userIdentitiesUpdatePost', +} + +test('getSemanticMethod: patch and post', (t) => { + const patchPostMethods: Method[] = ['PATCH', 'POST'] + t.is( + getSemanticMethod(patchPostMethods), + 'PATCH', + 'Semantic method should be PATCH when both PATCH and POST are available', + ) +}) + +test('getPreferredMethod: patch and post', (t) => { + const patchPostMethods: Method[] = ['PATCH', 'POST'] + t.is( + getPreferredMethod(patchPostMethods, 'PATCH', patchPostEndpoint), + 'PATCH', + 'Preferred method should be PATCH when both PATCH and POST are available', + ) +}) + +const deletePostEndpoint: OpenapiOperation = { + summary: '/user_identities/delete', + responses: { + '200': { + description: 'OK', + content: { + 'application/json': { + schema: { + properties: { + ok: { + type: 'boolean', + }, + }, + required: ['ok'], + }, + }, + }, + }, + '400': { + description: 'Bad Request', + }, + '401': { + description: 'Unauthorized', + }, + }, + security: [ + { api_key: [] }, + { pat_with_workspace: [] }, + { console_session: [] }, + ], + requestBody: { + content: { + 'application/json': { + schema: { + type: 'object', + properties: { + user_identity_id: { + type: 'string', + format: 'uuid', + }, + }, + required: ['user_identity_id'], + }, + }, + }, + }, + tags: ['/user_identities'], + operationId: 'userIdentitiesDeletePost', +} + +test('getSemanticMethod: delete and post', (t) => { + const deletePostMethods: Method[] = ['DELETE', 'POST'] + t.is( + getSemanticMethod(deletePostMethods), + 'DELETE', + 'Semantic method should be DELETE when both DELETE and POST are available', + ) +}) + +test('getPreferredMethod: delete and post', (t) => { + const deletePostMethods: Method[] = ['DELETE', 'POST'] + t.is( + getPreferredMethod(deletePostMethods, 'DELETE', deletePostEndpoint), + 'POST', + 'Preferred method should be POST when both DELETE and POST are available', + ) +}) diff --git a/src/lib/blueprint.ts b/src/lib/blueprint.ts index 068aeed..8a51ecc 100644 --- a/src/lib/blueprint.ts +++ b/src/lib/blueprint.ts @@ -157,7 +157,7 @@ interface IdProperty extends BaseProperty { jsonType: 'string' } -type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' +export type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' interface Context { codeSampleDefinitions: CodeSampleDefinition[] @@ -230,22 +230,28 @@ const createEndpoints = ( pathItem: OpenapiPathItem, context: Context, ): Endpoint[] => { + const validMethods: Method[] = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] + return Object.entries(pathItem) .filter( - ([, operation]) => typeof operation === 'object' && operation !== null, + ([method, operation]) => + validMethods.includes(method.toUpperCase() as Method) && + typeof operation === 'object' && + operation !== null, ) - .map(([method, operation]) => - createEndpoint( - method as Method, + .map(([method, operation]) => { + const uppercaseMethod = method.toUpperCase() as Method + return createEndpoint( + [uppercaseMethod], operation as OpenapiOperation, path, context, - ), - ) + ) + }) } const createEndpoint = ( - method: Method, + methods: Method[], operation: OpenapiOperation, path: string, context: Context, @@ -265,6 +271,8 @@ const createEndpoint = ( const deprecationMessage = parsedOperation['x-deprecated'] + const request = createRequest(methods, operation) + const endpoint = { title, path: endpointPath, @@ -273,8 +281,8 @@ const createEndpoint = ( isDeprecated, deprecationMessage, parameters: createParameters(operation), - request: createRequest(method, operation), response: createResponse(operation), + request, } return { @@ -309,16 +317,32 @@ const createParameter = (param: OpenapiParameter): Parameter => { } } -const createRequest = ( - method: Method, +export const createRequest = ( + methods: Method[], operation: OpenapiOperation, ): Request => { - const uppercaseMethod = openapiMethodToMethod(method) + if (methods.length === 0) { + // eslint-disable-next-line no-console + console.warn('At least one HTTP method should be specified') + } + + if (methods.length > 2) { + // eslint-disable-next-line no-console + console.warn('More than two methods detected. Was this intended?') + } + + if (!methods.includes('POST')) { + // eslint-disable-next-line no-console + console.warn('POST method is missing') + } + + const semanticMethod = getSemanticMethod(methods) + const preferredMethod = getPreferredMethod(methods, semanticMethod, operation) return { - methods: [uppercaseMethod], - semanticMethod: uppercaseMethod, - preferredMethod: uppercaseMethod, + methods, + semanticMethod, + preferredMethod, parameters: createRequestBody(operation), } } @@ -527,19 +551,42 @@ export const createProperties = ( }) } -const openapiMethodToMethod = (openapiMethod: string): Method => { - switch (openapiMethod) { - case 'get': - return 'GET' - case 'post': - return 'POST' - case 'put': - return 'PUT' - case 'delete': - return 'DELETE' - case 'patch': - return 'PATCH' - default: - return 'POST' +export const getSemanticMethod = (methods: Method[]): Method => { + if (methods.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return methods[0]! } + + const priorityOrder: Method[] = ['PUT', 'PATCH', 'POST', 'GET', 'DELETE'] + return methods.find((m) => priorityOrder.includes(m)) ?? 'POST' +} + +export const getPreferredMethod = ( + methods: Method[], + semanticMethod: Method, + operation: OpenapiOperation, +): Method => { + if (methods.length === 1) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return methods[0]! + } + + if (methods.includes('POST')) { + if (semanticMethod === 'GET' || semanticMethod === 'DELETE') { + const hasComplexParameters = + (operation.parameters?.some( + (param) => + param.schema?.type === 'array' || param.schema?.type === 'object', + ) ?? + false) || + operation.requestBody?.content?.['application/json']?.schema?.type === + 'object' + + if (hasComplexParameters) { + return 'POST' + } + } + } + + return semanticMethod } diff --git a/src/lib/openapi.ts b/src/lib/openapi.ts index cf6bed3..1f5c7f5 100644 --- a/src/lib/openapi.ts +++ b/src/lib/openapi.ts @@ -66,7 +66,7 @@ export interface OpenapiMediaType { } export interface OpenapiSchema { - type: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' + type?: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' properties?: Record items?: OpenapiSchema $ref?: string