Skip to content
Merged
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
67 changes: 63 additions & 4 deletions src/core/openapi/__tests__/open-api-docs-processor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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 () {
Expand All @@ -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 () {
Expand All @@ -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 }]);
});
8 changes: 8 additions & 0 deletions src/core/openapi/apex-type-wrappers/MethodMirrorWrapper.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DocCommentAnnotation, MethodMirror } from '@cparra/apex-reflection';
import * as yaml from 'js-yaml';

export class MethodMirrorWrapper {
constructor(public methodMirror: MethodMirror) {}
Expand All @@ -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 = <T>(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);
}
49 changes: 41 additions & 8 deletions src/core/openapi/open-api-docs-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand Down Expand Up @@ -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',
Expand All @@ -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<ApexDocParameterObject>('http-parameter'),
)
.filter((parameter) => parameter.in === 'path' && parameter.name)
.map((parameter) => parameter.name);
return [...new Set(names)];
}
}
Loading