Skip to content

Commit

Permalink
Add fixture0 as test + example in README.
Browse files Browse the repository at this point in the history
  • Loading branch information
mkrause committed May 25, 2024
1 parent d3f986c commit 4270afa
Show file tree
Hide file tree
Showing 9 changed files with 337 additions and 23 deletions.
179 changes: 177 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,190 @@

[![npm](https://img.shields.io/npm/v/openapi-to-effect.svg?style=flat-square)](https://www.npmjs.com/package/openapi-to-effect)
[![npm](https://img.shields.io/npm/v/openapi-to-effect.svg?style=flat)](https://www.npmjs.com/package/openapi-to-effect)
[![GitHub Actions](https://github.com/fortanix/openapi-to-effect/actions/workflows/nodejs.yml/badge.svg)](https://github.com/fortanix/openapi-to-effect/actions)

# openapi-to-effect

Generate [@effect/schema](https://www.npmjs.com/package/@effect/schema) definitions from an [OpenAPI](https://www.openapis.org) document.

Note that `@effect/schema` is currently in pre-stable version, and thus there will likely be breaking changes in the future.

**Features:**

- All output is TypeScript code.
- Fully configurable using a spec file, including hooks to customize the output (e.g. support more `format`s).
- Automatic detection of recursive definitions using graph analysis. In the output, the recursive references are wrapped in `Schema.suspend()`.
- Supports generating either one file per schema, or all schemas bundled into one file. When bundled, the schemas are sorted according to a topological sort algorithm so that schema dependencies are reflected in the output order.
- Pretty printing using `prettier`. Descriptions in the schema (e.g. `title`, `description`) are output as comments in the generated code. `title` fields are assumed to be single line comments, which are output as `//` comments, whereas `description` results in a block comment.

**Limitations:**

- We currently only support [OpenAPI v3.1](https://spec.openapis.org/oas/latest.html) documents.
- Only JSON is supported for the OpenAPI document format. For other formats like YAML, run it through a [converter](https://onlineyamltools.com/convert-yaml-to-json) first.
- The input must be a single OpenAPI document. Cross-document [references](https://swagger.io/docs/specification/using-ref/) are not currently supported.
- The `$allOf` operator currently only supports schemas of type `object`. Generic intersections are not currently supported.

## Usage

This package exposes an `openapi-to-effect` command:

```console
npx openapi-to-effect <command> <args>
```

### Generating `@effect/schema` code with the `gen` command

The `gen` command takes the path to an OpenAPI v3.1 document (in JSON format), the path to the output directory, and optionally a spec file to configure the output:

```console
npx openapi-to-effect gen ./api.json ./output --spec=./spec.ts
```
openapi-to-effect gen ./api.json ./generated --spec=./spec.ts

### Example

```console
npx openapi-to-effect gen ./api.json ./output --spec=./spec.ts
```

**api.json**

```json
{
"openapi": "3.1.0",
"info": {
"title": "Example API",
"version": "0.1.0"
},
"components": {
"schemas": {
"Category": {
"type": "object",
"properties": {
"name": { "type": "string" },
"subcategories": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Category"
},
"default": {}
}
},
"required": ["name"]
},
"User": {
"type": "object",
"properties": {
"id": {
"title": "Unique ID",
"type": "string",
"format": "uuid"
},
"name": {
"title": "The user's full name.",
"type": "string"
},
"last_logged_in": {
"title": "When the user last logged in.",
"type": "string", "format": "date-time"
},
"role": {
"title": "The user's role within the system.",
"description": "Roles:\n- ADMIN: Administrative permissions\n- USER: Normal permissions\n- AUDITOR: Read only permissions",
"type": "string",
"enum": ["ADMIN", "USER", "AUDITOR"]
},
"posts": {
"type": "array",
"items": { "$ref": "#/components/schemas/Post" }
}
},
"required": ["name", "last_logged_in", "role"]
}
}
}
}
```

**spec.ts**

```ts
import { type GenerationSpec } from '../../src/generation/generationSpec.ts';


export default {
generationMethod: { method: 'bundled', bundleName: 'fixture0' },
hooks: {},
runtime: {},
modules: {
'./Category.ts': {
definitions: [
{
action: 'generate-schema',
schemaId: 'Category',
typeDeclarationEncoded: `{
readonly name: string,
readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded }
}`,
typeDeclaration: `{
readonly name: string,
readonly subcategories: { readonly [key: string]: _Category }
}`,
},
],
},
},
} satisfies GenerationSpec;
```

**Output**

```ts
import { Schema as S } from '@effect/schema';

/* Category */

type _Category = {
readonly name: string;
readonly subcategories: { readonly [key: string]: _Category };
};
type _CategoryEncoded = {
readonly name: string;
readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded };
};
export const Category = S.Struct({
name: S.String,
subcategories: S.optional(
S.Record(
S.String,
S.suspend((): S.Schema<_Category, _CategoryEncoded> => Category),
),
{
default: () => ({}),
},
),
}).annotations({ identifier: 'Category' });
export type Category = S.Schema.Type<typeof Category>;
export type CategoryEncoded = S.Schema.Encoded<typeof Category>;

/* User */

export const User = S.Struct({
id: S.optional(S.UUID), // Unique ID
name: S.String, // The user's full name.
last_logged_in: S.Date, // When the user last logged in.
/**
* Roles:
*
* - ADMIN: Administrative permissions
* - USER: Normal permissions
* - AUDITOR: Read only permissions
*/
role: S.Literal('ADMIN', 'USER', 'AUDITOR'), // The user's role within the system.
interests: S.optional(S.Array(Category), {
default: () => [],
}),
}).annotations({ identifier: 'User' });
export type User = S.Schema.Type<typeof User>;
export type UserEncoded = S.Schema.Encoded<typeof User>;
```


Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,8 @@
"node": "node --import=tsx",
"check:types": "tsc --noEmit && echo 'No type errors found'",
"//test:unit": "node --import=tsx --test --test-reporter=spec \"**/*.test.ts\" # Note: glob requires Node v22",
"test:unit": "find src -name \"*.test.ts\" -exec node --import=tsx --test --test-reporter=spec {} ';'",
"test:integration": "find tests/integration -name \"*.test.ts\" -exec node --import=tsx --test --test-reporter=spec {} ';'",
"test:unit": "find src -type f -iname '*.test.ts' -print0 | xargs -0 node --import=tsx --test --test-reporter=spec",
"test:integration": "find tests/integration -type f -iname '*.test.ts' -print0 | xargs -0 node --import=tsx --test --test-reporter=spec",
"test": "npm run test:integration && npm run test:unit",
"test-watch": "node --import=tsx --test --test-reporter=spec --watch tests",
"_build": "NODE_ENV=production babel src --extensions=.ts,.tsx --delete-dir-on-start",
Expand Down
20 changes: 12 additions & 8 deletions src/generation/effSchemGen/schemaGen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@ import { isObjectSchema } from '../../analysis/GraphAnalyzer.ts';
import { type GenResult, GenResultUtil } from './genUtil.ts';


const assertUnreachable = (x: never): never => { throw new Error(`Should not happen`); };


export type Context = {
schemas: Record<string, OpenApiSchema>,
hooks: GenSpec.GenerationHooks,
Expand Down Expand Up @@ -44,12 +41,13 @@ export const generateForStringSchema = (ctx: Context, schema: OpenApi.NonArraySc
}

let baseSchema = `S.String`;
if (schema.format === 'uuid') {
baseSchema = `S.UUID`;
} else if (schema.format === 'byte') {
switch (schema.format) {
case 'uuid': baseSchema = `S.UUID`; break;
//case 'date': baseSchema = `S.Date`; // FIXME: validate lack of time component
case 'date-time': baseSchema = `S.Date`; break;
// FIXME: using `S.Base64` will result in `Uint8Array` rather than strings, which will break some downstream
// consumers.
//baseSchema = `S.Base64`;
//case 'byte': baseSchema = `S.Base64`; break;
}

let pipe: Array<string> = [baseSchema];
Expand Down Expand Up @@ -97,7 +95,13 @@ export const generateForNumberSchema = (
)`;
}

let pipe: Array<string> = [`S.Number`];
let baseSchema = `S.Number`;
switch (schema.format) {
//case 'date': baseSchema = `S.Date`; break; // FIXME: validate lack of time component
case 'date-time': baseSchema = `S.DateFromNumber.pipe(S.validDate())`; break;
}

let pipe: Array<string> = [baseSchema];
if (schema.type === 'integer') { pipe.push(`S.int()`); }

// Run hook
Expand Down
55 changes: 55 additions & 0 deletions tests/fixtures/fixture0_api.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
{
"openapi": "3.1.0",
"info": {
"title": "Example API",
"version": "0.1.0"
},
"components": {
"schemas": {
"Category": {
"type": "object",
"properties": {
"name": { "type": "string" },
"subcategories": {
"type": "object",
"additionalProperties": {
"$ref": "#/components/schemas/Category"
},
"default": {}
}
},
"required": ["name"]
},
"User": {
"type": "object",
"properties": {
"id": {
"title": "Unique ID",
"type": "string",
"format": "uuid"
},
"name": {
"title": "The user's full name.",
"type": "string"
},
"last_logged_in": {
"title": "When the user last logged in.",
"type": "string", "format": "date-time"
},
"role": {
"title": "The user's role within the system.",
"description": "Roles:\n- ADMIN: Administrative permissions\n- USER: Normal permissions\n- AUDITOR: Read only permissions",
"type": "string",
"enum": ["ADMIN", "USER", "AUDITOR"]
},
"interests": {
"type": "array",
"items": { "$ref": "#/components/schemas/Category" },
"default": []
}
},
"required": ["name", "last_logged_in", "role"]
}
}
}
}
27 changes: 27 additions & 0 deletions tests/fixtures/fixture0_spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

import { type GenerationSpec } from '../../src/generation/generationSpec.ts';


export default {
generationMethod: { method: 'bundled', bundleName: 'fixture0' },
hooks: {},
runtime: {},
modules: {
'./Category.ts': {
definitions: [
{
action: 'generate-schema',
schemaId: 'Category',
typeDeclarationEncoded: `{
readonly name: string,
readonly subcategories?: undefined | { readonly [key: string]: _CategoryEncoded }
}`,
typeDeclaration: `{
readonly name: string,
readonly subcategories: { readonly [key: string]: _Category }
}`,
},
],
},
},
} satisfies GenerationSpec;
50 changes: 50 additions & 0 deletions tests/integration/fixture0.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
/* Copyright (c) Fortanix, Inc.
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/

import { promisify } from 'node:util';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { exec as execCallback } from 'node:child_process';

import { Schema as S } from '@effect/schema';

import assert from 'node:assert/strict';
import { test } from 'node:test';


const exec = promisify(execCallback);

test('fixture0', { timeout: 30_000/*ms*/ }, async (t) => {
const before = async () => {
const cwd = path.dirname(fileURLToPath(import.meta.url));
console.log('Preparing fixture0...');
const { stdout, stderr } = await exec(`./generate_fixture.sh fixture0`, { cwd });
};
await before();

// @ts-ignore Will not type check until the generation is complete.
const fixture = await import('../project_simulation/generated/fixture0/fixture0.ts');

await t.test('User', async (t) => {
const user1 = {
id: '5141C532-90CA-4F12-B3EC-22776F9DDD80',
name: 'Alice',
last_logged_in: '2024-05-25T19:20:39.482Z',
role: 'USER',
interests: [
{ name: 'Music' },
],
};
assert.deepStrictEqual(S.decodeUnknownSync(fixture.User)(user1), {
...user1,
last_logged_in: new Date('2024-05-25T19:20:39.482Z'), // Transformed to Date
interests: [
{ name: 'Music', subcategories: {} }, // Added default value
],
});
});
});
Loading

0 comments on commit 4270afa

Please sign in to comment.