From a8064c23ec1815ae8d04b222e68d0227028c185d Mon Sep 17 00:00:00 2001
From: Swain Molster <swain.molster@lifeomic.com>
Date: Tue, 31 May 2022 12:56:22 -0400
Subject: [PATCH] feat: add tool for generating a publishable client

---
 .gitignore                              |   3 +-
 README.md                               | 118 ++++++++++--------------
 src/bin/__snapshots__/cli.test.ts.snap  |   2 +
 src/bin/cli.ts                          |  46 ++++++++-
 src/generate-axios-client.test.ts       |  95 +++++++++++--------
 src/generate-axios-client.ts            |  62 ++++++++++---
 src/generate-publishable-client.test.ts |  85 +++++++++++++++++
 src/generate-publishable-client.ts      |  49 ++++++++++
 src/integration.axios.test.ts           |  17 ++--
 9 files changed, 342 insertions(+), 135 deletions(-)
 create mode 100644 src/generate-publishable-client.test.ts
 create mode 100644 src/generate-publishable-client.ts

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<AxiosResponse<Endpoints['POST /posts']['Response']>> {
-    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<AxiosResponse<Endpoints['GET /posts']['Response']>> {
-    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<AxiosResponse<Endpoints["GET /posts"]["Response"]>> {
-    return this.client.request({
-      ...config,
-      method: "GET",
-      params: removePathParams("/posts", params),
-      url: substituteParams("/posts", params),
-    });
-  }
+  ): Promise<AxiosResponse<Endpoints["GET /posts"]["Response"]>>;
 
   putPost(
     data: Endpoints["PUT /posts/:id"]["Request"] &
       Endpoints["PUT /posts/:id"]["PathParams"],
     config?: AxiosRequestConfig
-  ): Promise<AxiosResponse<Endpoints["PUT /posts/:id"]["Response"]>> {
-    return this.client.request({
-      ...config,
-      method: "PUT",
-      data: removePathParams("/posts/:id", data),
-      url: substituteParams("/posts/:id", data),
-    });
-  }
+  ): Promise<AxiosResponse<Endpoints["PUT /posts/:id"]["Response"]>>;
 }
 `,
+      },
     },
   ];
 
   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<string> => {
+}: GenerateAxiosClientInput): Promise<GenerateAxiosClientOutput> => {
   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<AxiosResponse<Endpoints['${endpoint}']['Response']>>`;
+    })
+    .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<AxiosResponse<Endpoints['${endpoint}']['Response']>> {
+        ${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<string, string>;
+};
+
+export const generatePublishableClient = async ({
+  spec,
+  outputClass,
+}: GeneratePublishableClientInput): Promise<GeneratePublishableClientOutput> => {
+  const { files: baseFiles } = generatePublishableSchema({ spec });
+
+  const { declaration, javascript } = await generateAxiosClient({
+    spec,
+    outputClass,
+  });
+
+  const files: Record<string, string> = {
+    ...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({