Skip to content

Commit

Permalink
Merge pull request #18 from lifeomic/publish-client
Browse files Browse the repository at this point in the history
feat: add a tool for publishing nice client packages
  • Loading branch information
swain authored Jun 1, 2022
2 parents 8da04f2 + a8064c2 commit b87adba
Show file tree
Hide file tree
Showing 9 changed files with 342 additions and 135 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@ node_modules
coverage
.vscode
dist/
src/test-generated.ts
src/test-generated.js
src/test-generated.d.ts
118 changes: 49 additions & 69 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/' }));
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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
```
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/bin/__snapshots__/cli.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -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]
Expand Down
46 changes: 41 additions & 5 deletions src/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
}
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
});
}
},
Expand Down
95 changes: 58 additions & 37 deletions src/generate-axios-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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 = {
Expand All @@ -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);
});
});
});
Loading

0 comments on commit b87adba

Please sign in to comment.