From fb42bc57fe4db1ff0419f3db582e3eb26400f577 Mon Sep 17 00:00:00 2001 From: Cesar Parra Date: Tue, 28 Jul 2026 08:03:32 -0400 Subject: [PATCH] Enhance OpenApiDocsProcessor to replace wildcards with path templates --- .../__tests__/open-api-docs-processor.spec.ts | 67 +++++++++++++++++-- .../apex-type-wrappers/MethodMirrorWrapper.ts | 8 +++ src/core/openapi/open-api-docs-processor.ts | 49 +++++++++++--- 3 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/core/openapi/__tests__/open-api-docs-processor.spec.ts b/src/core/openapi/__tests__/open-api-docs-processor.spec.ts index 22ad5fec..d347985e 100644 --- a/src/core/openapi/__tests__/open-api-docs-processor.spec.ts +++ b/src/core/openapi/__tests__/open-api-docs-processor.spec.ts @@ -2,9 +2,12 @@ import { OpenApiDocsProcessor } from '../open-api-docs-processor'; import { OpenApiSettings } from '../openApiSettings'; import { SettingsBuilder } from '../../../test-helpers/SettingsBuilder'; import { DocCommentBuilder } from '../../../test-helpers/DocCommentBuilder'; +import { DocCommentAnnotationBuilder } from '../../../test-helpers/DocCommentAnnotationBuilder'; import { AnnotationBuilder } from '../../../test-helpers/AnnotationBuilder'; import { ClassMirrorBuilder } from '../../../test-helpers/ClassMirrorBuilder'; -import { NoLogger } from '../../../util/logger'; +import { MethodMirrorBuilder } from '../../../test-helpers/MethodMirrorBuilder'; +import { NoLogger } from '#utils/logger'; +import { DocCommentAnnotation } from '@cparra/apex-reflection'; const noLogger = new NoLogger(); @@ -24,7 +27,7 @@ it('should add a path based on the @UrlResource annotation on the class', functi const processor = new OpenApiDocsProcessor(noLogger); processor.onProcess(classMirror); - expect(processor.openApiModel.paths).toHaveProperty('/Account/'); + expect(processor.openApiModel.paths).toHaveProperty('/Account/{param1}'); }); it('should respect slashes', function () { @@ -39,7 +42,7 @@ it('should respect slashes', function () { const processor = new OpenApiDocsProcessor(noLogger); processor.onProcess(classMirror); - expect(processor.openApiModel.paths).toHaveProperty('/v1/Account/'); + expect(processor.openApiModel.paths).toHaveProperty('/v1/Account/{param1}'); }); it('should contain a path with a description when the class has an ApexDoc comment', function () { @@ -55,5 +58,61 @@ it('should contain a path with a description when the class has an ApexDoc comme const processor = new OpenApiDocsProcessor(noLogger); processor.onProcess(classMirror); - expect(processor.openApiModel.paths['/Account/'].description).toBe('My Description'); + expect(processor.openApiModel.paths['/Account/{param1}'].description).toBe('My Description'); +}); + +function httpParameter(name: string, location: 'path' | 'query' = 'path'): DocCommentAnnotation { + return new DocCommentAnnotationBuilder() + .withName('http-parameter') + .withBodyLines([`in: ${location}`, `name: ${name}`, 'schema:', ' type: string']) + .build(); +} + +function process(urlMapping: string, parameters: DocCommentAnnotation[]): OpenApiDocsProcessor { + const docComment = parameters.reduce( + (builder, parameter) => builder.addAnnotation(parameter), + new DocCommentBuilder(), + ); + const method = new MethodMirrorBuilder() + .addAnnotation(new AnnotationBuilder().withName('HttpGet').build()) + .withDocComment(docComment.build()) + .build(); + const classMirror = new ClassMirrorBuilder() + .addAnnotation(new AnnotationBuilder().addElementValue({ key: 'urlMapping', value: `'${urlMapping}'` }).build()) + .addMethod(method) + .build(); + + const processor = new OpenApiDocsProcessor(noLogger); + processor.onProcess(classMirror); + return processor; +} + +it.each([ + [ + 'a declared path parameter', + '/accounts/*/generate-report', + [httpParameter('accountId')], + '/accounts/{accountId}/generate-report', + ], + [ + 'multiple path parameters, in order', + '/accounts/*/contacts/*', + [httpParameter('accountId'), httpParameter('contactId')], + '/accounts/{accountId}/contacts/{contactId}', + ], + [ + 'generic names when there are more wildcards than parameters', + '/accounts/*/contacts/*', + [httpParameter('accountId')], + '/accounts/{accountId}/contacts/{param1}', + ], + ['non-path parameters ignored', '/accounts/*', [httpParameter('filter', 'query')], '/accounts/{param1}'], +])('should replace wildcards with path templates using %s', function (_name, urlMapping, parameters, expected) { + expect(process(urlMapping, parameters).openApiModel.paths).toHaveProperty(expected); +}); + +it('should not include parameter placeholders in the tag name', function () { + const processor = process('/accounts/*/generate-report', [httpParameter('accountId')]); + + expect(processor.openApiModel.tags).toEqual([{ name: 'Accounts Generate-Report', description: undefined }]); }); diff --git a/src/core/openapi/apex-type-wrappers/MethodMirrorWrapper.ts b/src/core/openapi/apex-type-wrappers/MethodMirrorWrapper.ts index 1de60917..4395890a 100644 --- a/src/core/openapi/apex-type-wrappers/MethodMirrorWrapper.ts +++ b/src/core/openapi/apex-type-wrappers/MethodMirrorWrapper.ts @@ -1,4 +1,5 @@ import { DocCommentAnnotation, MethodMirror } from '@cparra/apex-reflection'; +import * as yaml from 'js-yaml'; export class MethodMirrorWrapper { constructor(public methodMirror: MethodMirror) {} @@ -8,4 +9,11 @@ export class MethodMirrorWrapper { public getDocCommentAnnotation = (annotationName: string): DocCommentAnnotation | undefined => this.methodMirror.docComment?.annotations.find((annotation) => annotation.name.toLowerCase() === annotationName); + + /** The body of these annotations is expected to be in YAML format. */ + public getDocCommentAnnotationsAs = (annotationName: string): T[] => + (this.methodMirror.docComment?.annotations ?? []) + .filter((annotation) => annotation.name.toLowerCase() === annotationName) + .map((annotation) => yaml.load(annotation.bodyLines.join('\n')) as T | undefined) + .filter((parsed): parsed is T => parsed !== undefined); } diff --git a/src/core/openapi/open-api-docs-processor.ts b/src/core/openapi/open-api-docs-processor.ts index 244ba265..6f192056 100644 --- a/src/core/openapi/open-api-docs-processor.ts +++ b/src/core/openapi/open-api-docs-processor.ts @@ -6,6 +6,8 @@ import { OpenApiSettings } from './openApiSettings'; import { MethodParser } from './parsers/MethodParser'; import { camel2title } from '#utils/string-utils'; import { createOpenApiFile } from './openapi-type-file'; +import { ApexDocParameterObject } from './apex-doc-types'; +import { MethodMirrorWrapper } from './apex-type-wrappers/MethodMirrorWrapper'; export class OpenApiDocsProcessor { protected readonly _fileContainer: FileContainer; @@ -29,21 +31,21 @@ export class OpenApiDocsProcessor { } onProcess(type: Type): void { - const endpointPath = this.getEndpointPath(type); - if (!endpointPath) { + // We can safely cast to a ClassMirror, since only these support the @RestResource annotation + const typeAsClass = type as ClassMirror; + + const endpoint = this.getEndpoint(typeAsClass); + if (!endpoint) { return; } + const { path: endpointPath, tagName } = endpoint; this.openApiModel.paths[endpointPath] = {}; if (type.docComment?.description) { this.openApiModel.paths[endpointPath].description = type.docComment.description; } - // We can safely cast to a ClassMirror, since only these support the @RestResource annotation - const typeAsClass = type as ClassMirror; - // Add tags for this Apex class to the OpenApi model - const tagName = camel2title(endpointPath); this.openApiModel.tags.push({ name: tagName, description: type.docComment?.description, @@ -72,7 +74,7 @@ export class OpenApiDocsProcessor { this._fileContainer.pushFile(page); }; - private getEndpointPath(type: Type): string | null { + private getEndpoint(type: ClassMirror): { path: string; tagName: string } | null { const restResourceAnnotation = type.annotations.find((element) => element.name.toLowerCase() === 'restresource'); const urlMapping = restResourceAnnotation?.elementValues?.find( (element) => element.key.toLowerCase() === 'urlmapping', @@ -86,6 +88,37 @@ export class OpenApiDocsProcessor { // Salesforce @RestResource annotations already require a leading slash, // so no need to check for it. // See URL Guidelines: https://developer.salesforce.com/docs/atlas.en-us.apexcode.meta/apexcode/apex_classes_annotation_rest_resource.htm - return urlMapping.value.replaceAll('"', '').replaceAll("'", '').replaceAll('/*', '/'); + const rawPath = urlMapping.value.replaceAll('"', '').replaceAll("'", ''); + const segments = rawPath.split('/'); + + // Salesforce allows wildcards (*) in URL mappings, but these are not valid in an OpenApi path. + // We transform each wildcard into a path template parameter, matching them in order with + // the path parameters declared through @http-parameter doc annotations. + // See https://spec.openapis.org/oas/v3.1.0#parameter-locations + const parameterNames = segments.includes('*') ? this.getPathParameterNames(type) : []; + let wildcardIndex = 0; + let fallbackIndex = 0; + const path = segments + .map((segment) => + segment === '*' ? `{${parameterNames[wildcardIndex++] ?? `param${++fallbackIndex}`}}` : segment, + ) + .join('/'); + + // The tag name is derived from the path without its wildcards, to keep it readable. + return { path, tagName: camel2title(segments.filter((segment) => segment !== '*').join('/')) }; + } + + /** + * Returns the names of all parameters declared as `in: path` through @http-parameter + * doc annotations on the class's methods, in the order the methods are declared. + */ + private getPathParameterNames(type: ClassMirror): string[] { + const names = type.methods + .flatMap((method) => + new MethodMirrorWrapper(method).getDocCommentAnnotationsAs('http-parameter'), + ) + .filter((parameter) => parameter.in === 'path' && parameter.name) + .map((parameter) => parameter.name); + return [...new Set(names)]; } }