Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add option to pass in custom handlebars templates #1268 #1995

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,13 @@ $ openapi --help
--postfixServices Service name postfix (default: "Service")
--postfixModels Model name postfix
--request <value> Path to custom request file
--templateOverrides <list> List of template overrides in the format name:template
-h, --help display help for command

Examples
$ openapi --input ./spec.json --output ./generated
$ openapi --input ./spec.json --output ./generated --client xhr
$ openapi --input ./spec.json --output ./generated --templateOverrides index:\"Hello World\" exportService:./templates/service.hbs"
```

Documentation
Expand Down
8 changes: 8 additions & 0 deletions bin/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
const path = require('path');
const { program } = require('commander');
const pkg = require('../package.json');
const getTemplateOverrides = list =>
list?.reduce((result, override) => {
const [name, value] = override.split(':');
if (name && value) result[name] = value;
return result;
}, {});

const params = program
.name('openapi')
Expand All @@ -24,6 +30,7 @@ const params = program
.option('--postfixServices <value>', 'Service name postfix', 'Service')
.option('--postfixModels <value>', 'Model name postfix')
.option('--request <value>', 'Path to custom request file')
.option('--templateOverrides <name:template...>', 'List of template overrides in the format name:template')
.parse(process.argv)
.opts();

Expand All @@ -45,6 +52,7 @@ if (OpenAPI) {
postfixServices: params.postfixServices,
postfixModels: params.postfixModels,
request: params.request,
templateOverrides: getTemplateOverrides(params.templateOverrides),
})
.then(() => {
process.exit(0);
Expand Down
5 changes: 5 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { TemplateOverrideNames } from '../types';
import { HttpClient } from './HttpClient';
import { Indent } from './Indent';
import { parse as parseV2 } from './openApi/v2';
Expand Down Expand Up @@ -28,6 +29,7 @@ export type Options = {
postfixModels?: string;
request?: string;
write?: boolean;
templateOverrides?: Partial<Record<TemplateOverrideNames, string>>;
};

/**
Expand All @@ -49,6 +51,7 @@ export type Options = {
* @param postfixModels Model name postfix
* @param request Path to custom request file
* @param write Write the files to disk (true or false)
* @param templateOverrides Override any template with a custom implementation
*/
export const generate = async ({
input,
Expand All @@ -66,13 +69,15 @@ export const generate = async ({
postfixModels = '',
request,
write = true,
templateOverrides,
}: Options): Promise<void> => {
const openApi = isString(input) ? await getOpenApiSpec(input) : input;
const openApiVersion = getOpenApiVersion(openApi);
const templates = registerHandlebarTemplates({
httpClient,
useUnionTypes,
useOptions,
templateOverrides,
});

switch (openApiVersion) {
Expand Down
41 changes: 41 additions & 0 deletions src/utils/preCompileTemplate.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { readFileSync } from 'fs';
import handlebars from 'handlebars';

import { preCompileTemplate } from './preCompileTemplate';

jest.mock('fs');
jest.mock('handlebars');

describe('preCompileTemplate', () => {
it('returns undefined if no file is provided', () => {
expect(preCompileTemplate()).toBeUndefined();
});

it('reads the file, trims it, and precompiles it', () => {
(readFileSync as jest.Mock).mockReturnValue({ toString: () => ' mock template ' });
(handlebars.precompile as jest.Mock).mockReturnValue('"mock template spec"');

const result = preCompileTemplate('mock-file.hbs');

expect(readFileSync).toHaveBeenCalledWith('mock-file.hbs', 'utf8');
expect(handlebars.precompile).toHaveBeenCalledWith('mock template', {
strict: true,
noEscape: true,
preventIndent: true,
knownHelpersOnly: true,
knownHelpers: {
ifdef: true,
equals: true,
notEquals: true,
containsSpaces: true,
union: true,
intersection: true,
enumerator: true,
escapeComment: true,
escapeDescription: true,
camelCase: true,
},
});
expect(result).toBe('mock template spec');
});
});
35 changes: 35 additions & 0 deletions src/utils/preCompileTemplate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { readFileSync } from 'fs';
import handlebars from 'handlebars';
import { extname } from 'path';

/**
* Precompiles a Handlebars template from a given file.
*
* @param {string | undefined} file - The path to the file containing the Handlebars template.
* @returns {TemplateSpecification | undefined} - The precompiled template as a string, ready to be exported.
*/
export function preCompileTemplate(file?: string): TemplateSpecification | undefined {
if (!file) return;

const template = extname(file) === '.hbs' ? readFileSync(file, 'utf8').toString().trim() : file;
const templateSpec = handlebars.precompile(template, {
strict: true,
noEscape: true,
preventIndent: true,
knownHelpersOnly: true,
knownHelpers: {
ifdef: true,
equals: true,
notEquals: true,
containsSpaces: true,
union: true,
intersection: true,
enumerator: true,
escapeComment: true,
escapeDescription: true,
camelCase: true,
},
});

return eval(`(function(){return ${templateSpec} }());`);

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

eval seems safe enough, although I suspect code quality tools will complain.

Here is an another (extremely hacky) alternative using a temp file which gets resolved

import { readFileSync, writeFileSync, mkdtempSync } from 'fs';

...

    const tempDir = mkdtempSync('template-');
    const tempFilePath = join(tempDir, 'template.js');
    writeFileSync(tempFilePath, `module.exports = ${compiled};`);

    const module = require(resolve(tempFilePath));
    execSync(`rm -rf ${tempDir}`);
    return module;

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the observation. Yeah, code quality tools will warn about the safety of this to prevent developers from shipping code that could be exploited. Seeing this is a library and the provider of the template to be evaluated is the developer using the library, I think this is a safe implementation of the eval function — and the cleanest solution I could find. Writing and reading to disk on every precompilation would significantly affect performance and might encounter problems with disk access permissions.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same thoughts. I wish we could use some other api besides handlebars.precompile. But I could not find any that works.

}
73 changes: 63 additions & 10 deletions src/utils/registerHandlebarTemplates.spec.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,74 @@
import Handlebars from 'handlebars/runtime';

import { HttpClient } from '../HttpClient';
import * as preCompiler from './preCompileTemplate';
import { registerHandlebarTemplates } from './registerHandlebarTemplates';

jest.mock('handlebars/runtime', () => ({
template: jest.fn((file: string) => file),
registerPartial: jest.fn((file: string) => file),
registerHelper: jest.fn((file: string) => file),
}));

jest.mock('./preCompileTemplate', () => ({
preCompileTemplate: jest.fn((file: string) => file),
}));

describe('registerHandlebarTemplates', () => {
it('should return correct templates', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should register handlebar templates', () => {
registerHandlebarTemplates({
httpClient: HttpClient.FETCH,
useOptions: false,
useUnionTypes: false,
});

expect(Handlebars.template).toHaveBeenCalled();
expect(Handlebars.registerPartial).toHaveBeenCalled();
expect(preCompiler.preCompileTemplate).toHaveBeenCalled();
});

it('should return default templates', () => {
const templates = registerHandlebarTemplates({
httpClient: HttpClient.FETCH,
useOptions: false,
useUnionTypes: false,
});

expect(templates.index).toHaveProperty('compiler');
expect(templates.exports.model).toHaveProperty('compiler');
expect(templates.exports.schema).toHaveProperty('compiler');
expect(templates.exports.service).toHaveProperty('compiler');
expect(templates.core.settings).toHaveProperty('compiler');
expect(templates.core.apiError).toHaveProperty('compiler');
expect(templates.core.apiRequestOptions).toHaveProperty('compiler');
expect(templates.core.apiResult).toHaveProperty('compiler');
expect(templates.core.request).toHaveProperty('compiler');
});

it('should allow template overrides', () => {
const templates = registerHandlebarTemplates({
httpClient: HttpClient.FETCH,
useOptions: false,
useUnionTypes: false,
templateOverrides: {
index: 'override',
exportService: 'override',
settings: 'override',
},
});
expect(templates.index).toBeDefined();
expect(templates.exports.model).toBeDefined();
expect(templates.exports.schema).toBeDefined();
expect(templates.exports.service).toBeDefined();
expect(templates.core.settings).toBeDefined();
expect(templates.core.apiError).toBeDefined();
expect(templates.core.apiRequestOptions).toBeDefined();
expect(templates.core.apiResult).toBeDefined();
expect(templates.core.request).toBeDefined();

expect(templates.index).toBe('override');
expect(templates.exports.model).toHaveProperty('compiler');
expect(templates.exports.schema).toHaveProperty('compiler');
expect(templates.exports.service).toBe('override');
expect(templates.core.settings).toBe('override');
expect(templates.core.apiError).toHaveProperty('compiler');
expect(templates.core.apiRequestOptions).toHaveProperty('compiler');
expect(templates.core.apiResult).toHaveProperty('compiler');
expect(templates.core.request).toHaveProperty('compiler');
});
});
Loading