Skip to content

Commit 8354a75

Browse files
chrisradekChristopher Radek
and
Christopher Radek
authored
Add support for converting OpenAPI3 specs to TypeSpec (microsoft#3663)
fix microsoft#3038 This PR updates the `@typespec/openapi3` package to support converting OpenAPI3 specs to TypeSpec. ## Example usage: 1. `npm install @typespec/openapi3` 2. `npx openapi3-to-tsp compile --output-dir ./tsp-output /path/to/openapi-yaml-or-json` ## What's supported - Parse OpenAPI3 specs in yml/json formats (via 3rd party package) - Generates file namespace based on OpenAPI3 service name - Populates `@info` decorator with OpenAPI3 service info - Converts `#/components/schemas` into TypeSpec models/scalars. - Converts `#/components/parameters` into TypeSpec models/model properties as appropriate. - Generates a response model for every operation/statusCode/contentType combination. - Operation tags - Generates TypeSpec operations with routes/Http Method decorators - Generates docs/extension decorators - Most schema decorators - Model inheritance via `allOf` - Discriminators ## What's not supported (yet) - auth - deprecated directive - combining multiple versions of an OpenAPI3-defined service into a single TypeSpec project - converting `#/components/requestBodies` and `#/components/responses` into models - TypeSpec doesn't seem to generate these and I didn't find examples in the wild where they were defined _and_ actually used so deprioritized. - emitting warnings/FIXMEs for unexpected/unsupported scenarios - Probably a lot more that I'm still discovering ## Notes When going through the TypeSpec -> OpenAPI3 -> TypeSpec loop, the generated TypeSpec is going to be larger than the original. The biggest contribution towards this is because I'm currently generating a model for every possible response on every operation. I can definitely pare this down with some simple heuristics that take into account what default statusCode/contentTypes are, and extract the referenced body type directly in the operation's return signature. I can also eliminate the `@get` decorators, `@route("/")` routes, and likely use some of the response models provided by TypeSpec.Http. However - if I'm using this tool to convert from OpenAPI3 to TypeSpec - I thought it might be preferable to be more explicit in the generated output so there's no mystery on how things actually get defined. Will be interested in feedback on this. ## Testing For tests, I generate TypeSpec files for a number of OpenAPI3 specs. Most of the OpenAPI3 specs I generated from our TypeSpec samples packages. Then I'm able to compare the generated TypeSpec to the corresponding original TypeSpec file. I've also been diffing the OpenAPI3 specs generated from the original and generated TypeSpec files <- these are what typically show no changes outside of known unsupported conversions (e.g. auth). --------- Co-authored-by: Christopher Radek <Christopher.Radek@microsoft.com>
1 parent 47c93c8 commit 8354a75

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

53 files changed

+5229
-91
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
changeKind: feature
3+
packages:
4+
- "@typespec/openapi3"
5+
---
6+
7+
Adds support for converting OpenAPI3 specs to TypeSpec via the new tsp-openapi3 CLI included in the `@typespec/openapi3` package.

docs/emitters/openapi3/cli.md

+224
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
---
2+
title: OpenAPI3 to TypeSpec
3+
---
4+
5+
# tsp-openapi3 CLI
6+
7+
## Converting OpenAPI 3 into TypeSpec
8+
9+
This package includes the `tsp-openapi3` CLI for converting OpenAPI 3 specs into TypeSpec.
10+
The generated TypeSpec depends on the `@typespec/http`, `@typespec/openapi` and `@typespec/openapi3` libraries.
11+
12+
### Usage
13+
14+
1. via the command line
15+
16+
```bash
17+
tsp-openapi3 ./openapi3spec.yml --output-dir ./tsp-output
18+
```
19+
20+
### tsp-openapi3 arguments
21+
22+
The path to the OpenAPI3 yaml or json file **must** be passed as a position argument.
23+
24+
The named arguments are:
25+
26+
| Name | Type | Required | Description |
27+
| ---------- | ------- | -------- | ---------------------------------------------------------------------------------------- |
28+
| output-dir | string | required | The output directory for generated TypeSpec files. Will be created if it does not exist. |
29+
| help | boolean | optional | Show help. |
30+
31+
## Examples
32+
33+
### 1. Convert component schemas into models
34+
35+
All schemas present at `#/components/schemas` will be converted into a model or scalar as appropriate.
36+
37+
<table>
38+
<tr>
39+
<td>OpenAPI3</td>
40+
<td>TypeSpec</td>
41+
</tr>
42+
<!-- --------------------------------------------------- SCENARIO 1.1 ----------------------------------------------------------- -->
43+
<tr>
44+
<td>
45+
46+
```yml
47+
components:
48+
schemas:
49+
Widget:
50+
type: object
51+
required:
52+
- id
53+
- weight
54+
- color
55+
properties:
56+
id:
57+
type: string
58+
weight:
59+
type: integer
60+
format: int32
61+
color:
62+
type: string
63+
enum:
64+
- red
65+
- blue
66+
uuid:
67+
type: string
68+
format: uuid
69+
```
70+
71+
</td>
72+
<td>
73+
74+
```tsp
75+
model Widget {
76+
id: string;
77+
weight: int32;
78+
color: "red" | "blue";
79+
}
80+
81+
@format("uuid")
82+
scalar uuid extends string;
83+
```
84+
85+
</td>
86+
</tr>
87+
</table>
88+
89+
### 2. Convert component parameters into models or fields
90+
91+
All parameters present at `#/components/parameters` will be converted to a field in a model. If the model doesn't exist in `#/components/schemas`, then it will be created.
92+
93+
<table>
94+
<tr>
95+
<td>OpenAPI3</td>
96+
<td>TypeSpec</td>
97+
</tr>
98+
<!-- --------------------------------------------------- SCENARIO 2.1 ----------------------------------------------------------- -->
99+
<tr>
100+
<td>
101+
102+
```yml
103+
components:
104+
parameters:
105+
Widget.id:
106+
name: id
107+
in: path
108+
required: true
109+
schema:
110+
type: string
111+
schemas:
112+
Widget:
113+
type: object
114+
required:
115+
- id
116+
- weight
117+
- color
118+
properties:
119+
id:
120+
type: string
121+
weight:
122+
type: integer
123+
format: int32
124+
color:
125+
type: string
126+
enum:
127+
- red
128+
- blue
129+
```
130+
131+
</td>
132+
<td>
133+
134+
```tsp
135+
model Widget {
136+
@path id: string;
137+
weight: int32;
138+
color: "red" | "blue";
139+
}
140+
```
141+
142+
</td>
143+
</tr>
144+
<!-- --------------------------------------------------- SCENARIO 2.2 ----------------------------------------------------------- -->
145+
<tr>
146+
<td>
147+
148+
```yml
149+
components:
150+
parameters:
151+
Foo.id:
152+
name: id
153+
in: path
154+
required: true
155+
schema:
156+
type: string
157+
```
158+
159+
</td>
160+
<td>
161+
162+
```tsp
163+
model Foo {
164+
@path id: string;
165+
}
166+
```
167+
168+
</td>
169+
</tr>
170+
</table>
171+
172+
### 3. Convert path routes to operations
173+
174+
All routes using one of the HTTP methods supported by `@typespec/http` will be converted into operations at the file namespace level. A model is also generated for each operation response.
175+
176+
At this time, no automatic operation grouping under interfaces is performed.
177+
178+
<table>
179+
<tr>
180+
<td>OpenAPI3</td>
181+
<td>TypeSpec</td>
182+
</tr>
183+
<!-- --------------------------------------------------- SCENARIO 3.1 ----------------------------------------------------------- -->
184+
<tr>
185+
<td>
186+
187+
```yml
188+
paths:
189+
/{id}:
190+
get:
191+
operationId: readWidget
192+
parameters:
193+
- name: id
194+
in: path
195+
required: true
196+
schema:
197+
type: string
198+
responses:
199+
"200":
200+
description: The request has succeeded.
201+
content:
202+
application/json:
203+
schema:
204+
$ref: "#/components/schemas/Widget"
205+
```
206+
207+
</td>
208+
<td>
209+
210+
```tsp
211+
/**
212+
* The request has succeeded.
213+
*/
214+
model readWidget200ApplicationJsonResponse {
215+
@statusCode statusCode: 200;
216+
@bodyRoot body: Widget;
217+
}
218+
219+
@route("/{id}") @get op readWidget(@path id: string): readWidget200ApplicationJsonResponse;
220+
```
221+
222+
</td>
223+
</tr>
224+
</table>

docs/emitters/openapi3/reference/index.mdx

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import TabItem from '@theme/TabItem';
1010

1111
# Overview
1212

13-
TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding
13+
TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec
1414

1515
## Install
1616

packages/openapi3/README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# @typespec/openapi3
22

3-
TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding
3+
TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec
44

55
## Install
66

packages/openapi3/cmd/tsp-openapi3.js

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#!/usr/bin/env node
2+
import { main } from "../dist/src/cli/cli.js";
3+
4+
main().catch((error) => {
5+
// eslint-disable-next-line no-console
6+
console.error(error);
7+
8+
process.exit(1);
9+
});

packages/openapi3/package.json

+11-3
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "@typespec/openapi3",
33
"version": "0.57.0",
44
"author": "Microsoft Corporation",
5-
"description": "TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding",
5+
"description": "TypeSpec library for emitting OpenAPI 3.0 from the TypeSpec REST protocol binding and converting OpenAPI3 to TypeSpec",
66
"homepage": "https://typespec.io",
77
"readme": "https://github.com/microsoft/typespec/blob/main/README.md",
88
"license": "MIT",
@@ -16,6 +16,9 @@
1616
"keywords": [
1717
"typespec"
1818
],
19+
"bin": {
20+
"tsp-openapi3": "cmd/tsp-openapi3.js"
21+
},
1922
"type": "module",
2023
"main": "dist/src/index.js",
2124
"tspMain": "lib/main.tsp",
@@ -34,7 +37,7 @@
3437
},
3538
"scripts": {
3639
"clean": "rimraf ./dist ./temp",
37-
"build": "npm run gen-extern-signature && tsc -p . && npm run lint-typespec-library",
40+
"build": "npm run gen-version && npm run gen-extern-signature && tsc -p . && npm run lint-typespec-library",
3841
"watch": "tsc -p . --watch",
3942
"gen-extern-signature": "tspd --enable-experimental gen-extern-signature .",
4043
"lint-typespec-library": "tsp compile . --warn-as-error --import @typespec/library-linter --no-emit",
@@ -44,14 +47,17 @@
4447
"test:ci": "vitest run --coverage --reporter=junit --reporter=default",
4548
"lint": "eslint . --max-warnings=0",
4649
"lint:fix": "eslint . --fix",
47-
"regen-docs": "tspd doc . --enable-experimental --output-dir ../../docs/emitters/openapi3/reference"
50+
"regen-docs": "tspd doc . --enable-experimental --output-dir ../../docs/emitters/openapi3/reference",
51+
"regen-specs": "cross-env RECORD=true vitest run",
52+
"gen-version": "node scripts/generate-version.js"
4853
},
4954
"files": [
5055
"lib/*.tsp",
5156
"dist/**",
5257
"!dist/test/**"
5358
],
5459
"dependencies": {
60+
"@readme/openapi-parser": "~2.6.0",
5561
"yaml": "~2.4.5"
5662
},
5763
"peerDependencies": {
@@ -62,6 +68,7 @@
6268
},
6369
"devDependencies": {
6470
"@types/node": "~18.11.19",
71+
"@types/yargs": "~17.0.32",
6572
"@typespec/compiler": "workspace:~",
6673
"@typespec/http": "workspace:~",
6774
"@typespec/library-linter": "workspace:~",
@@ -72,6 +79,7 @@
7279
"@vitest/coverage-v8": "^1.6.0",
7380
"@vitest/ui": "^1.6.0",
7481
"c8": "^10.1.2",
82+
"cross-env": "~7.0.3",
7583
"rimraf": "~5.0.7",
7684
"typescript": "~5.5.3",
7785
"vitest": "^1.6.0"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
// @ts-check
2+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
3+
import { join } from "path";
4+
import { fileURLToPath } from "url";
5+
const root = fileURLToPath(new URL("..", import.meta.url).href);
6+
const distDir = join(root, "dist");
7+
const versionTarget = join(distDir, "version.js");
8+
9+
function loadPackageJson() {
10+
const packageJsonPath = join(root, "package.json");
11+
return JSON.parse(readFileSync(packageJsonPath, "utf-8"));
12+
}
13+
14+
function main() {
15+
const pkg = loadPackageJson();
16+
17+
const version = pkg.version;
18+
19+
if (!existsSync(distDir)) {
20+
mkdirSync(distDir, { recursive: true });
21+
}
22+
23+
const versionJs = `export const version = "${version}";`;
24+
writeFileSync(versionTarget, versionJs);
25+
}
26+
27+
main();
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export interface ConvertCliArgs {
2+
"output-dir": string;
3+
path: string;
4+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import oaParser from "@readme/openapi-parser";
2+
import { resolvePath } from "@typespec/compiler";
3+
import { OpenAPI3Document } from "../../../types.js";
4+
import { CliHost } from "../../types.js";
5+
import { handleInternalCompilerError } from "../../utils.js";
6+
import { ConvertCliArgs } from "./args.js";
7+
import { generateMain } from "./generators/generate-main.js";
8+
import { transform } from "./transforms/transforms.js";
9+
10+
export async function convertAction(host: CliHost, args: ConvertCliArgs) {
11+
// attempt to read the file
12+
const fullPath = resolvePath(process.cwd(), args.path);
13+
const model = await parseOpenApiFile(fullPath);
14+
const program = transform(model);
15+
let mainTsp: string;
16+
try {
17+
mainTsp = await generateMain(program);
18+
} catch (err) {
19+
handleInternalCompilerError(err);
20+
}
21+
22+
if (args["output-dir"]) {
23+
await host.mkdirp(args["output-dir"]);
24+
await host.writeFile(resolvePath(args["output-dir"], "main.tsp"), mainTsp);
25+
}
26+
}
27+
28+
function parseOpenApiFile(path: string): Promise<OpenAPI3Document> {
29+
return oaParser.bundle(path) as Promise<OpenAPI3Document>;
30+
}

0 commit comments

Comments
 (0)