diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 325921f..079a8f0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,3 +15,4 @@ jobs: uses: fastify/workflows/.github/workflows/plugins-ci.yml@v3 with: license-check: true + lint: true diff --git a/README.md b/README.md index 6047f98..9f74dbd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,98 @@ You can also override the default configuration by passing the [`serializerOpts` This module is already used as default by Fastify. If you need to provide to your server instance a different version, refer to [the official doc](https://www.fastify.io/docs/latest/Reference/Server/#schemacontroller). +### fast-json-stringify Standalone + +`fast-json-stringify@v4.1.0` introduces the [standalone feature](https://github.com/fastify/fast-json-stringify#standalone) that let you to pre-compile your schemas and use them in your application for a faster startup. + +To use this feature, you must be aware of the following: + +1. You must generate and save the application's compiled schemas. +2. Read the compiled schemas from the file and provide them back to your Fastify application. + + +#### Generate and save the compiled schemas + +Fastify helps you to generate the serialization schemas functions and it is your choice to save them where you want. +To accomplish this, you must use a new compiler: `@fastify/fast-json-stringify-compiler/standalone`. + +You must provide 2 parameters to this compiler: + +- `readMode: false`: a boolean to indicate that you want generate the schemas functions string. +- `storeFunction`" a sync function that must store the source code of the schemas functions. You may provide an async function too, but you must manage errors. + +When `readMode: false`, **the compiler is meant to be used in development ONLY**. + + +```js +const { StandaloneSerializer } = require('@fastify/fast-json-stringify-compiler') + +const factory = StandaloneSerializer({ + readMode: false, + storeFunction (routeOpts, schemaSerializationCode) { + // routeOpts is like: { schema, method, url, httpStatus } + // schemaSerializationCode is a string source code that is the compiled schema function + const fileName = generateFileName(routeOpts) + fs.writeFileSync(path.join(__dirname, fileName), schemaSerializationCode) + } +}) + +const app = fastify({ + jsonShorthand: false, + schemaController: { + compilersFactory: { + buildSerializer: factory + } + } +}) + +// ... add all your routes with schemas ... + +app.ready().then(() => { + // at this stage all your schemas are compiled and stored in the file system + // now it is important to turn off the readMode +}) +``` + +#### Read the compiled schemas functions + +At this stage, you should have a file for every route's schema. +To use them, you must use the `@fastify/fast-json-stringify-compiler/standalone` with the parameters: + +- `readMode: true`: a boolean to indicate that you want read and use the schemas functions string. +- `restoreFunction`" a sync function that must return a function to serialize the route's payload. + +Important keep away before you continue reading the documentation: + +- when you use the `readMode: true`, the application schemas are not compiled (they are ignored). So, if you change your schemas, you must recompile them! +- as you can see, you must relate the route's schema to the file name using the `routeOpts` object. You may use the `routeOpts.schema.$id` field to do so, it is up to you to define a unique schema identifier. + +```js +const { StandaloneSerializer } = require('@fastify/fast-json-stringify-compiler') + +const factory = StandaloneSerializer({ + readMode: true, + restoreFunction (routeOpts) { + // routeOpts is like: { schema, method, url, httpStatus } + const fileName = generateFileName(routeOpts) + return require(path.join(__dirname, fileName)) + } +}) + +const app = fastify({ + jsonShorthand: false, + schemaController: { + compilersFactory: { + buildSerializer: factory + } + } +}) + +// ... add all your routes with schemas as before... + +app.listen({ port: 3000 }) +``` + ### How it works This module provide a factory function to produce [Serializer Compilers](https://www.fastify.io/docs/latest/Reference/Server/#serializercompiler) functions. diff --git a/index.d.ts b/index.d.ts index 31506ad..0d7ec24 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,12 +1,30 @@ import { Options as FJSOptions } from 'fast-json-stringify' +export type { Options } from 'fast-json-stringify' + export type SerializerCompiler = ( externalSchemas: unknown, options: FJSOptions ) => (doc: any) => string; -export declare function SerializerSelector(): SerializerCompiler; +export type RouteDefinition = { + method: string, + url: string, + httpStatus: string, + schema?: unknown, +} -export type { Options } from 'fast-json-stringify' +export interface StandaloneOptions { + readMode: Boolean, + storeFunction?(opts: RouteDefinition, schemaSerializationCode: string): void, + restoreFunction?(opts: RouteDefinition): void, +} + +declare function SerializerSelector(): SerializerCompiler; +declare function StandaloneSerializer(options: StandaloneOptions): SerializerCompiler; -export default SerializerSelector; \ No newline at end of file +export default SerializerSelector; +export { + SerializerSelector, + StandaloneSerializer, +}; diff --git a/index.js b/index.js index 836b4c7..a021ea1 100644 --- a/index.js +++ b/index.js @@ -18,3 +18,6 @@ function responseSchemaCompiler (fjsOpts, { schema /* method, url, httpStatus */ } module.exports = SerializerSelector +module.exports.default = SerializerSelector +module.exports.SerializerSelector = SerializerSelector +module.exports.StandaloneSerializer = require('./standalone') diff --git a/package.json b/package.json index 0e3dc69..f5f8b3b 100644 --- a/package.json +++ b/package.json @@ -5,9 +5,11 @@ "main": "index.js", "types": "index.d.ts", "scripts": { + "lint": "standard", "lint:fix": "standard --fix", - "unit": "tap --100 test/**/*.test.js", - "test": "standard && npm run unit && npm run test:typescript", + "unit": "tap test/**/*.test.js", + "test": "npm run unit && npm run test:typescript", + "posttest": "rimraf test/fjs-generated*.js", "test:typescript": "tsd" }, "repository": { @@ -23,6 +25,8 @@ "homepage": "https://github.com/fastify/fast-json-stringify-compiler#readme", "devDependencies": { "fastify": "^4.0.0", + "rimraf": "^3.0.2", + "sanitize-filename": "^1.6.3", "standard": "^17.0.0", "tap": "^16.0.0", "tsd": "^0.22.0" diff --git a/standalone.js b/standalone.js new file mode 100644 index 0000000..39b4937 --- /dev/null +++ b/standalone.js @@ -0,0 +1,42 @@ +'use strict' + +const SerializerSelector = require('./index') + +function StandaloneSerializer (options = { readMode: true }) { + if (options.readMode === true && typeof options.restoreFunction !== 'function') { + throw new Error('You must provide a function for the restoreFunction-option when readMode ON') + } + + if (options.readMode !== true && typeof options.storeFunction !== 'function') { + throw new Error('You must provide a function for the storeFunction-option when readMode OFF') + } + + if (options.readMode === true) { + // READ MODE: it behalf only in the restore function provided by the user + return function wrapper () { + return function (opts) { + return options.restoreFunction(opts) + } + } + } + + // WRITE MODE: it behalf on the default SerializerSelector, wrapping the API to run the Ajv Standalone code generation + const factory = SerializerSelector() + return function wrapper (externalSchemas, serializerOpts = {}) { + // to generate the serialization source code, this option is mandatory + serializerOpts.mode = 'standalone' + + const compiler = factory(externalSchemas, serializerOpts) + return function (opts) { // { schema/*, method, url, httpPart */ } + const serializeFuncCode = compiler(opts) + + options.storeFunction(opts, serializeFuncCode) + + // eslint-disable-next-line no-new-func + return new Function(serializeFuncCode) + } + } +} + +module.exports = StandaloneSerializer +module.exports.default = StandaloneSerializer diff --git a/test/plugin.test.js b/test/plugin.test.js index d467720..df2582b 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -25,9 +25,7 @@ const externalSchemas2 = Object.freeze({ } }) -const fastifyFjsOptionsDefault = Object.freeze({ - customOptions: {} -}) +const fastifyFjsOptionsDefault = Object.freeze({}) t.test('basic usage', t => { t.plan(1) diff --git a/test/standalone.test.js b/test/standalone.test.js new file mode 100644 index 0000000..fa26e61 --- /dev/null +++ b/test/standalone.test.js @@ -0,0 +1,213 @@ +'use strict' + +const fs = require('fs') +const path = require('path') +const t = require('tap') +const fastify = require('fastify') +const sanitize = require('sanitize-filename') + +const { StandaloneSerializer: FjsStandaloneCompiler } = require('../') + +function generateFileName (routeOpts) { + return `/fjs-generated-${sanitize(routeOpts.schema.$id)}-${routeOpts.method}-${routeOpts.httpPart}-${sanitize(routeOpts.url)}.js` +} + +t.test('errors', t => { + t.plan(2) + t.throws(() => { + FjsStandaloneCompiler() + }, 'missing restoreFunction') + t.throws(() => { + FjsStandaloneCompiler({ readMode: false }) + }, 'missing storeFunction') +}) + +t.test('generate standalone code', t => { + t.plan(5) + + const base = { + $id: 'urn:schema:base', + definitions: { + hello: { type: 'string' } + }, + type: 'object', + properties: { + hello: { $ref: '#/definitions/hello' } + } + } + + const refSchema = { + $id: 'urn:schema:ref', + type: 'object', + properties: { + hello: { $ref: 'urn:schema:base#/definitions/hello' } + } + } + + const endpointSchema = { + schema: { + $id: 'urn:schema:endpoint', + $ref: 'urn:schema:ref' + } + } + + const schemaMap = { + [base.$id]: base, + [refSchema.$id]: refSchema + } + + const factory = FjsStandaloneCompiler({ + readMode: false, + storeFunction (routeOpts, schemaSerializerCode) { + t.same(routeOpts, endpointSchema) + t.type(schemaSerializerCode, 'string') + fs.writeFileSync(path.join(__dirname, '/fjs-generated.js'), schemaSerializerCode) + t.pass('stored the serializer function') + } + }) + + const compiler = factory(schemaMap) + compiler(endpointSchema) + t.pass('compiled the endpoint schema') + + t.test('usage standalone code', t => { + t.plan(3) + const standaloneSerializer = require('./fjs-generated') + t.ok(standaloneSerializer) + + const valid = standaloneSerializer({ hello: 'world' }) + t.same(valid, JSON.stringify({ hello: 'world' })) + + const invalid = standaloneSerializer({ hello: [] }) + t.same(invalid, '{"hello":""}') + }) +}) + +t.test('fastify integration - writeMode', async t => { + t.plan(4) + + const factory = FjsStandaloneCompiler({ + readMode: false, + storeFunction (routeOpts, schemaSerializationCode) { + const fileName = generateFileName(routeOpts) + t.ok(routeOpts) + fs.writeFileSync(path.join(__dirname, fileName), schemaSerializationCode) + t.pass(`stored the serializer function ${fileName}`) + }, + restoreFunction () { + t.fail('write mode ON') + } + }) + + const app = buildApp(factory) + await app.ready() +}) + +t.test('fastify integration - writeMode forces standalone', async t => { + t.plan(4) + + const factory = FjsStandaloneCompiler({ + readMode: false, + storeFunction (routeOpts, schemaSerializationCode) { + const fileName = generateFileName(routeOpts) + t.ok(routeOpts) + fs.writeFileSync(path.join(__dirname, fileName), schemaSerializationCode) + t.pass(`stored the serializer function ${fileName}`) + }, + restoreFunction () { + t.fail('write mode ON') + } + }) + + const app = buildApp(factory, { + mode: 'not-standalone', + rounding: 'ceil' + }) + + await app.ready() +}) + +t.test('fastify integration - readMode', async t => { + t.plan(6) + + const factory = FjsStandaloneCompiler({ + readMode: true, + storeFunction () { + t.fail('read mode ON') + }, + restoreFunction (routeOpts) { + const fileName = generateFileName(routeOpts) + t.pass(`restore the serializer function ${fileName}}`) + return require(path.join(__dirname, fileName)) + } + }) + + const app = buildApp(factory) + await app.ready() + + let res = await app.inject({ + url: '/foo', + method: 'POST' + }) + t.equal(res.statusCode, 200) + t.equal(res.payload, JSON.stringify({ hello: 'world' })) + + res = await app.inject({ + url: '/bar?lang=it', + method: 'GET' + }) + t.equal(res.statusCode, 200) + t.equal(res.payload, JSON.stringify({ lang: 'en' })) +}) + +function buildApp (factory, serializerOpts) { + const app = fastify({ + exposeHeadRoutes: false, + jsonShorthand: false, + schemaController: { + compilersFactory: { + buildSerializer: factory + } + }, + serializerOpts + }) + + app.addSchema({ + $id: 'urn:schema:foo', + type: 'object', + properties: { + name: { type: 'string' }, + id: { type: 'integer' } + } + }) + + app.post('/foo', { + schema: { + response: { + 200: { + $id: 'urn:schema:response', + type: 'object', + properties: { + hello: { $ref: 'urn:schema:foo#/properties/name' } + } + } + } + } + }, () => { return { hello: 'world' } }) + + app.get('/bar', { + schema: { + response: { + 200: { + $id: 'urn:schema:response:bar', + type: 'object', + properties: { + lang: { type: 'string', enum: ['it', 'en'] } + } + } + } + } + }, () => { return { lang: 'en' } }) + + return app +} diff --git a/test/types/index.test-d.ts b/test/types/index.test-d.ts new file mode 100644 index 0000000..9980fc9 --- /dev/null +++ b/test/types/index.test-d.ts @@ -0,0 +1,16 @@ +import { expectType } from "tsd"; +import SerializerSelector, { + SerializerCompiler, + SerializerSelector as SerializerSelectorNamed, + StandaloneSerializer, +} from "../.."; + +{ + const compiler = SerializerSelector(); + expectType(compiler); +} + +{ + const compiler = SerializerSelectorNamed(); + expectType(compiler); +} \ No newline at end of file diff --git a/test/types/index.test.ts b/test/types/index.test.ts deleted file mode 100644 index 67caf0d..0000000 --- a/test/types/index.test.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { expectType } from "tsd"; -import SerializerSelector, { SerializerCompiler } from "../.."; - -const compiler = SerializerSelector(); - -expectType(compiler); \ No newline at end of file diff --git a/test/types/standalone.test-d.ts b/test/types/standalone.test-d.ts new file mode 100644 index 0000000..789b40e --- /dev/null +++ b/test/types/standalone.test-d.ts @@ -0,0 +1,21 @@ +import { expectAssignable, expectType } from "tsd"; + +import { StandaloneSerializer, RouteDefinition } from "../../"; +import { SerializerCompiler } from "../.."; + +const reader = StandaloneSerializer({ + readMode: true, + restoreFunction: (route: RouteDefinition) => { + expectAssignable(route) + }, +}); +expectType(reader); + +const writer = StandaloneSerializer({ + readMode: false, + storeFunction: (route: RouteDefinition, code: string) => { + expectAssignable(route) + expectAssignable(code) + }, +}); +expectType(writer); \ No newline at end of file