diff --git a/.gitignore b/.gitignore index 1567ad0..6b82d14 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules coverage .vscode dist/ -src/test-generated.ts \ No newline at end of file +src/test-generated.js +src/test-generated.d.ts \ No newline at end of file diff --git a/README.md b/README.md index 76fb8c6..f347810 100644 --- a/README.md +++ b/README.md @@ -56,78 +56,16 @@ one-schema generate-axios-client \ --format ``` -The output (in `generated-client.ts`): +This command will output two files: -```typescript -/* eslint-disable */ -import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; +- `generated-client.js` +- `generated-client.d.ts` -export type Endpoints = { - 'POST /posts': { - Request: { - message: string; - }; - PathParams: {}; - Response: Post; - }; - 'GET /posts': { - Request: { - filter: string; - }; - PathParams: {}; - Response: Post[]; - }; -}; - -export type Post = { - /** - * The post's unique identifier. - */ - id: string; - /** - * The post message. - */ - message: string; -}; - -// ... various helpers ... - -export class Client { - constructor(private readonly client: AxiosInstance) {} - - createPost( - data: Endpoints['POST /posts']['Request'] & - Endpoints['POST /posts']['PathParams'], - config?: AxiosRequestConfig, - ): Promise> { - return this.client.request({ - ...config, - method: 'POST', - data: removePathParams('/posts', data), - url: substituteParams('/posts', data), - }); - } - - listPosts( - params: Endpoints['GET /posts']['Request'] & - Endpoints['GET /posts']['PathParams'], - config?: AxiosRequestConfig, - ): Promise> { - return this.client.request({ - ...config, - method: 'GET', - params: removePathParams('/posts', params), - url: substituteParams('/posts', params), - }); - } -} -``` - -Usage: +How to use the generated client: ```typescript import axios from 'axios'; -import { Client } from './generated-client.ts'; +import { Client } from './generated-client'; // Provide any AxiosInstance, customized to your needs. const client = new Client(axios.create({ baseURL: 'https://my.api.com/' })); @@ -215,7 +153,7 @@ import Router from 'koa-router'; import { implementSchema } from '@lifeomic/one-schema'; -import { Schema } from './generated-api.ts'; +import { Schema } from './generated-api'; const router = new Router(); @@ -264,7 +202,36 @@ Meta: ``` ```bash -one-schema generate-publishable \ +one-schema generate-publishable-schema \ + --schema schema.yml \ + --output output-directory +``` + +The `output-directory` will have this file structure: + +``` +output-directory/ + package.json + schema.json + schema.yaml +``` + +### Distributing Clients + +Use the `generate-publishable-client` command in concert with the `Meta.PackageJSON` entry to generate a ready-to-publish NPM artifact containing a ready-to-use client. + +```yaml +# schema.yml +Meta: + PackageJSON: + name: desired-package-name + description: A description of the package + # ... any other desired package.json values +# ... +``` + +```bash +one-schema generate-publishable-client \ --schema schema.yml \ --output output-directory ``` @@ -276,6 +243,19 @@ output-directory/ package.json schema.json schema.yaml + index.js + index.d.ts +``` + +The generated client code is identical to the output of `generate-axios-client`: + +```typescript +import axios from 'axios'; +import { Client } from './output-directory'; + +const client = new Client(axios.create(...)) + +// use the client ``` ### OpenAPI Spec generation diff --git a/src/bin/__snapshots__/cli.test.ts.snap b/src/bin/__snapshots__/cli.test.ts.snap index fb35250..303bf19 100644 --- a/src/bin/__snapshots__/cli.test.ts.snap +++ b/src/bin/__snapshots__/cli.test.ts.snap @@ -11,6 +11,7 @@ Commands: cli.ts generate-open-api-spec Generates an OpenAPI v3.1.0 spec using the specified schema and options. cli.ts generate-publishable-schema Generates a publishable schema artifact. + cli.ts generate-publishable-client Generates a publishable client artifact. Options: --help Show help [boolean] @@ -30,6 +31,7 @@ Commands: cli.ts generate-open-api-spec Generates an OpenAPI v3.1.0 spec using the specified schema and options. cli.ts generate-publishable-schema Generates a publishable schema artifact. + cli.ts generate-publishable-client Generates a publishable client artifact. Options: --help Show help [boolean] diff --git a/src/bin/cli.ts b/src/bin/cli.ts index 4394445..252a359 100644 --- a/src/bin/cli.ts +++ b/src/bin/cli.ts @@ -1,6 +1,6 @@ #!/usr/bin/env node import { mkdirSync, writeFileSync } from 'fs'; -import { extname } from 'path'; +import * as path from 'path'; import yargs = require('yargs'); import { format, BuiltInParserName } from 'prettier'; import { dump } from 'js-yaml'; @@ -10,10 +10,10 @@ import { generateAPITypes } from '../generate-api-types'; import { loadSchemaFromFile, SchemaAssumptions } from '../meta-schema'; import { toOpenAPISpec } from '../openapi'; import { generatePublishableSchema } from '../generate-publishable-schema'; -import path = require('path'); +import { generatePublishableClient } from '../generate-publishable-client'; const getPrettierParser = (outputFilename: string): BuiltInParserName => { - const extension = extname(outputFilename).replace('.', ''); + const extension = path.extname(outputFilename).replace('.', ''); if (['yml', 'yaml'].includes(extension)) { return 'yaml'; } @@ -126,7 +126,16 @@ const program = yargs(process.argv.slice(2)) outputClass: argv.className, }); - writeGeneratedFile(argv.output, output, { format: argv.format }); + writeGeneratedFile(argv.output.replace('.ts', '.js'), output.javascript, { + format: argv.format, + }); + writeGeneratedFile( + argv.output.replace('.ts', '.d.ts'), + output.declaration, + { + format: argv.format, + }, + ); }, ) .command( @@ -191,7 +200,34 @@ const program = yargs(process.argv.slice(2)) for (const [filename, content] of Object.entries(files)) { writeGeneratedFile(path.resolve(argv.output, filename), content, { - format: true, + format: argv.format, + }); + } + }, + ) + .command( + 'generate-publishable-client', + 'Generates a publishable client artifact.', + (y) => + getCommonOptions(y).option('className', { + type: 'string', + description: 'The name of the generated client class.', + default: 'Client', + }), + async (argv) => { + const spec = loadSchemaFromFile( + argv.schema, + parseAssumptions(argv.assumptions), + ); + + const { files } = await generatePublishableClient({ + spec, + outputClass: argv.className, + }); + + for (const [filename, content] of Object.entries(files)) { + writeGeneratedFile(path.resolve(argv.output, filename), content, { + format: argv.format, }); } }, diff --git a/src/generate-axios-client.test.ts b/src/generate-axios-client.test.ts index 4789dfc..6d4f641 100644 --- a/src/generate-axios-client.test.ts +++ b/src/generate-axios-client.test.ts @@ -6,11 +6,15 @@ import { format } from 'prettier'; describe('generate', () => { const generateAndFormat = (input: GenerateAxiosClientInput) => - generateAxiosClient(input).then((source) => - format(source, { parser: 'typescript' }), - ); + generateAxiosClient(input).then((source) => ({ + javascript: format(source.javascript, { parser: 'babel' }), + declaration: format(source.declaration, { parser: 'typescript' }), + })); - const FIXTURES: { input: GenerateAxiosClientInput; expected: string }[] = [ + const FIXTURES: { + input: GenerateAxiosClientInput; + expected: { javascript: string; declaration: string }; + }[] = [ { input: { outputClass: 'Client', @@ -55,7 +59,49 @@ describe('generate', () => { }, }, }, - expected: `/* eslint-disable */ + expected: { + javascript: `/* eslint-disable */ + +const substituteParams = (url, params) => + Object.entries(params).reduce( + (url, [name, value]) => url.replace(":" + name, value), + url + ); + +const removePathParams = (url, params) => + Object.entries(params).reduce( + (accum, [name, value]) => + url.includes(":" + name) ? accum : { ...accum, [name]: value }, + {} + ); + +class Client { + constructor(client) { + this.client = client; + } + + getPosts(params, config) { + return this.client.request({ + ...config, + method: "GET", + params: removePathParams("/posts", params), + url: substituteParams("/posts", params), + }); + } + + putPost(data, config) { + return this.client.request({ + ...config, + method: "PUT", + data: removePathParams("/posts/:id", data), + url: substituteParams("/posts/:id", data), + }); + } +} + +module.exports.Client = Client; +`, + declaration: `/* eslint-disable */ import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from "axios"; export type Endpoints = { @@ -82,56 +128,31 @@ export type Endpoints = { }; }; -const substituteParams = (url: string, params: any) => - Object.entries(params).reduce( - (url, [name, value]) => url.replace(":" + name, value as any), - url - ); - -const removePathParams = (url: string, params: any) => - Object.entries(params).reduce( - (accum, [name, value]) => - url.includes(":" + name) ? accum : { ...accum, [name]: value }, - {} - ); - -export class Client { - constructor(private readonly client: AxiosInstance) {} +export declare class Client { + constructor(client: AxiosInstance); getPosts( params: Endpoints["GET /posts"]["Request"] & Endpoints["GET /posts"]["PathParams"], config?: AxiosRequestConfig - ): Promise> { - return this.client.request({ - ...config, - method: "GET", - params: removePathParams("/posts", params), - url: substituteParams("/posts", params), - }); - } + ): Promise>; putPost( data: Endpoints["PUT /posts/:id"]["Request"] & Endpoints["PUT /posts/:id"]["PathParams"], config?: AxiosRequestConfig - ): Promise> { - return this.client.request({ - ...config, - method: "PUT", - data: removePathParams("/posts/:id", data), - url: substituteParams("/posts/:id", data), - }); - } + ): Promise>; } `, + }, }, ]; FIXTURES.forEach(({ input, expected }, idx) => { test(`fixture ${idx}`, async () => { const result = await generateAndFormat(input); - expect(result).toStrictEqual(expected); + expect(result.javascript).toStrictEqual(expected.javascript); + expect(result.declaration).toStrictEqual(expected.declaration); }); }); }); diff --git a/src/generate-axios-client.ts b/src/generate-axios-client.ts index 25e3681..7929c5a 100644 --- a/src/generate-axios-client.ts +++ b/src/generate-axios-client.ts @@ -7,33 +7,64 @@ export type GenerateAxiosClientInput = { outputClass: string; }; +export type GenerateAxiosClientOutput = { + javascript: string; + declaration: string; +}; + export const generateAxiosClient = async ({ spec, outputClass, -}: GenerateAxiosClientInput): Promise => { +}: GenerateAxiosClientInput): Promise => { validateSchema(spec); - return [ + + const declaration = [ '/* eslint-disable */', "import { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios';", '', await generateEndpointTypes(spec), ` -const substituteParams = (url: string, params: any) => +export declare class ${outputClass} { + + constructor(client: AxiosInstance); + + ${Object.entries(spec.Endpoints) + .map(([endpoint, { Name }]) => { + const [method] = endpoint.split(' '); + const paramsName = method === 'GET' ? 'params' : 'data'; + + return ` + ${Name}( + ${paramsName}: Endpoints['${endpoint}']['Request'] & + Endpoints['${endpoint}']['PathParams'], + config?: AxiosRequestConfig + ): Promise>`; + }) + .join('\n\n')} +}`.trim(), + ].join('\n'); + + const javascript = ` +/* eslint-disable */ + +const substituteParams = (url, params) => Object.entries(params).reduce( - (url, [name, value]) => url.replace(":" + name, value as any), + (url, [name, value]) => url.replace(":" + name, value), url ); -const removePathParams = (url: string, params: any) => +const removePathParams = (url, params) => Object.entries(params).reduce( (accum, [name, value]) => url.includes(':' + name) ? accum : { ...accum, [name]: value }, {} ); -export class ${outputClass} { +class ${outputClass} { - constructor(private readonly client: AxiosInstance) {} + constructor(client) { + this.client = client; + } ${Object.entries(spec.Endpoints) .map(([endpoint, { Name }]) => { @@ -41,11 +72,7 @@ export class ${outputClass} { const paramsName = method === 'GET' ? 'params' : 'data'; return ` - ${Name}( - ${paramsName}: Endpoints['${endpoint}']['Request'] & - Endpoints['${endpoint}']['PathParams'] , - config?: AxiosRequestConfig - ): Promise> { + ${Name}(${paramsName}, config) { return this.client.request({ ...config, method: '${method}', @@ -56,6 +83,13 @@ export class ${outputClass} { `; }) .join('\n\n')} -}`.trim(), - ].join('\n'); +} + +module.exports.${outputClass} = ${outputClass}; + `.trim(); + + return { + javascript, + declaration, + }; }; diff --git a/src/generate-publishable-client.test.ts b/src/generate-publishable-client.test.ts new file mode 100644 index 0000000..266cd2c --- /dev/null +++ b/src/generate-publishable-client.test.ts @@ -0,0 +1,85 @@ +import { generateAxiosClient } from './generate-axios-client'; +import { generatePublishableClient } from './generate-publishable-client'; +import { generatePublishableSchema } from './generate-publishable-schema'; + +test('skips generating a package.json if there is no PackageJSON entry', async () => { + const result = await generatePublishableClient({ + outputClass: 'Client', + spec: { + Endpoints: { + 'GET /posts': { + Name: 'listPosts', + Response: {}, + Request: {}, + }, + }, + }, + }); + + expect(result.files['package.json']).toBeUndefined(); +}); + +test('generates the correct files when there is a PackageJSON entry', async () => { + const spec = { + Meta: { + PackageJSON: { + name: '@lifeomic/test-service-schema', + description: 'The OneSchema for a test-service', + testObject: { + some: 'value', + }, + }, + }, + Endpoints: { + 'GET /posts': { + Name: 'listPosts', + Response: {}, + Request: {}, + }, + }, + }; + const result = await generatePublishableClient({ + outputClass: 'Client', + spec, + }); + + expect(Object.keys(result.files)).toStrictEqual([ + 'schema.json', + 'schema.yaml', + 'package.json', + 'index.js', + 'index.d.ts', + ]); + + const schemaArtifact = generatePublishableSchema({ spec }); + + expect(result.files['schema.json']).toStrictEqual( + schemaArtifact.files['schema.json'], + ); + + expect(result.files['schema.yaml']).toStrictEqual( + schemaArtifact.files['schema.yaml'], + ); + + const client = await generateAxiosClient({ outputClass: 'Client', spec }); + + expect(result.files['index.js']).toStrictEqual(client.javascript); + + expect(result.files['index.d.ts']).toStrictEqual(client.declaration); + + expect(result.files['package.json']).toStrictEqual( + ` +{ + "name": "@lifeomic/test-service-schema", + "description": "The OneSchema for a test-service", + "testObject": { + "some": "value" + }, + "main": "index.js", + "peerDependencies": { + "axios": "*" + } +} +`.trim(), + ); +}); diff --git a/src/generate-publishable-client.ts b/src/generate-publishable-client.ts new file mode 100644 index 0000000..4d78aea --- /dev/null +++ b/src/generate-publishable-client.ts @@ -0,0 +1,49 @@ +import { OneSchemaDefinition } from '.'; +import { generateAxiosClient } from './generate-axios-client'; +import { generatePublishableSchema } from './generate-publishable-schema'; + +export type GeneratePublishableClientInput = { + spec: OneSchemaDefinition; + outputClass: string; +}; + +export type GeneratePublishableClientOutput = { + /** + * A map of filename -> file content to generate. + */ + files: Record; +}; + +export const generatePublishableClient = async ({ + spec, + outputClass, +}: GeneratePublishableClientInput): Promise => { + const { files: baseFiles } = generatePublishableSchema({ spec }); + + const { declaration, javascript } = await generateAxiosClient({ + spec, + outputClass, + }); + + const files: Record = { + ...baseFiles, + 'index.js': javascript, + 'index.d.ts': declaration, + }; + + if (files['package.json']) { + files['package.json'] = JSON.stringify( + { + ...JSON.parse(files['package.json']), + main: 'index.js', + peerDependencies: { + axios: '*', + }, + }, + null, + 2, + ); + } + + return { files }; +}; diff --git a/src/integration.axios.test.ts b/src/integration.axios.test.ts index f186555..f18a4ad 100644 --- a/src/integration.axios.test.ts +++ b/src/integration.axios.test.ts @@ -6,12 +6,13 @@ import { GenerateAxiosClientInput, } from './generate-axios-client'; -const TEST_GEN_FILE = `${__dirname}/test-generated.ts`; +const testGeneratedFile = (ext: string) => `${__dirname}/test-generated${ext}`; const generateAndFormat = (input: GenerateAxiosClientInput) => - generateAxiosClient(input).then((source) => - format(source, { parser: 'typescript' }), - ); + generateAxiosClient(input).then((source) => ({ + javascript: format(source.javascript, { parser: 'babel' }), + declaration: format(source.declaration, { parser: 'typescript' }), + })); describe('integration tests', () => { test('compile + execute', async () => { @@ -58,12 +59,10 @@ describe('integration tests', () => { const output = await generateAndFormat({ spec, outputClass: 'Service' }); - writeFileSync(TEST_GEN_FILE, output); + writeFileSync(testGeneratedFile('.js'), output.javascript); + writeFileSync(testGeneratedFile('.d.ts'), output.declaration); - const { Service } = await import( - // @ts-ignore - TEST_GEN_FILE - ); + const { Service } = await import(testGeneratedFile('.js')); const mockRequest = jest.fn(); const instance = new Service({