Skip to content

Commit d3a6f80

Browse files
zachkirschgraphite-app[bot]dsinghvi
authored
feat(cli): add 'fern export' command (#7586)
* feat(cli): add 'fern export' command * Add E2E test * Update packages/cli/cli/src/commands/export/generateOpenAPIForWorkspaces.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Simplify * Update packages/cli/cli/src/commands/export/converters/typeConverter.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Update packages/cli/cli/src/commands/export/security.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Update packages/cli/cli/src/commands/export/converters/typeConverter.ts Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> * Simplify * Handle case where dir doesn't exist * fix eslint * fix format * Add changelog * chore: update changelog * Add docs * Remove hideOnThisPage --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com> Co-authored-by: Deep Singhvi <[email protected]> Co-authored-by: zachkirsch <[email protected]>
1 parent 7435ba8 commit d3a6f80

File tree

24 files changed

+2692
-2
lines changed

24 files changed

+2692
-2
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
## 0.64.16
2+
**`(feat):`** Add `fern export` command to export API to an OpenAPI spec.
3+
4+

fern/pages/cli-api/commands.mdx

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
title: 'Commands'
33
description: 'Complete reference for all Fern CLI commands for generating SDKs and developer documentation.'
44
subtitle: 'Learn about the Fern CLI commands.'
5-
hideOnThisPage: true
5+
max-toc-depth: 3
66
---
77

88
| Command | Description |
@@ -18,14 +18,15 @@ hideOnThisPage: true
1818
| [`fern docs dev`](#fern-docs-dev) | Run local documentation preview server |
1919
| [`fern generate --docs`](#fern-generate---docs) | Build & publish documentation updates |
2020

21-
## SDK Generation Commands
21+
## Generation Commands
2222

2323
| Command | Description |
2424
| --------------------------------------------------- | ---------------------------------------------------------------------------------------- |
2525
| [`fern generate`](#fern-generate) | Build & publish SDK updates |
2626
| [`fern write-definition`](#fern-write-definition) | Convert OpenAPI specifications to [Fern Definition](/learn/api-definition/fern/overview) |
2727
| [`fern write-overrides`](#fern-write-overrides) | Create OpenAPI customizations |
2828
| [`fern generator upgrade`](#fern-generator-upgrade) | Update SDK generators to latest versions |
29+
| [`fern export`](#fern-export) | Export an OpenAPI spec for your API |
2930

3031
## Detailed Command Documentation
3132

@@ -491,3 +492,25 @@ Use `--group` to upgrade generators within a specific group in your `generators.
491492
fern generator upgrade --group public
492493
```
493494

495+
### `fern export`
496+
497+
Use `fern export` to generate an OpenAPI spec for your API.
498+
499+
<Callout intent='info'>
500+
Generally, this is only useful if you're defining your API in another format,
501+
like the [Fern Definition](/learn/api-definition/fern/overview).
502+
</Callout>
503+
504+
<CodeBlock title="terminal">
505+
```bash
506+
fern export [--api <api>] <path>
507+
```
508+
</CodeBlock>
509+
510+
#### `--api`
511+
512+
Use `--api` to specify the API to write the OpenAPI for if you have multiple defined in your `fern/apis/` folder.
513+
514+
```bash
515+
fern export --api public-api path/to/openapi.yml
516+
```

packages/cli/api-importers/v2-importer-commons/src/converters/ExampleConverter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ export class ExampleConverter extends AbstractConverter<AbstractConverterContext
649649
{ type: "object" },
650650
{ type: "array" }
651651
]
652+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
652653
} as any);
653654

654655
// Find properties in the example that are not defined in the schema

packages/cli/cli/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@
9292
"@types/lodash-es": "^4.17.12",
9393
"@types/semver": "^7.5.8",
9494
"@types/tar": "^6.1.11",
95+
"@types/url-join": "4.0.1",
9596
"@types/validate-npm-package-name": "^4.0.0",
9697
"@types/yargs": "^17.0.28",
9798
"ansi-escapes": "^5.0.0",
@@ -106,11 +107,13 @@
106107
"latest-version": "^9.0.0",
107108
"lodash-es": "^4.17.21",
108109
"ora": "^7.0.1",
110+
"openapi-types": "^12.1.3",
109111
"semver": "^7.6.2",
110112
"tar": "^6.2.1",
111113
"tmp-promise": "^3.0.3",
112114
"tsup": "^8.3.5",
113115
"undici": "^6.21.1",
116+
"url-join": "^5.0.0",
114117
"validate-npm-package-name": "^5.0.1",
115118
"yaml": "^2.4.5",
116119
"yargs": "^17.4.1",

packages/cli/cli/src/cli.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { addGeneratorCommands, addGetOrganizationCommand } from "./cliV2";
2626
import { addGeneratorToWorkspaces } from "./commands/add-generator/addGeneratorToWorkspaces";
2727
import { diff } from "./commands/diff/diff";
2828
import { previewDocsWorkspace } from "./commands/docs-dev/devDocsWorkspace";
29+
import { generateOpenAPIForWorkspaces } from "./commands/export/generateOpenAPIForWorkspaces";
2930
import { formatWorkspaces } from "./commands/format/formatWorkspaces";
3031
import { generateDynamicIrForWorkspaces } from "./commands/generate-dynamic-ir/generateDynamicIrForWorkspaces";
3132
import { generateFdrApiDefinitionForWorkspaces } from "./commands/generate-fdr/generateFdrApiDefinitionForWorkspaces";
@@ -182,6 +183,7 @@ async function tryRunCli(cliContext: CliContext) {
182183
});
183184
addGenerateJsonschemaCommand(cli, cliContext);
184185
addWriteDocsDefinitionCommand(cli, cliContext);
186+
addExportCommand(cli, cliContext);
185187

186188
// CLI V2 Sanctioned Commands
187189
addGetOrganizationCommand(cli, cliContext);
@@ -1340,3 +1342,38 @@ function addWriteDocsDefinitionCommand(cli: Argv<GlobalCliOptions>, cliContext:
13401342
}
13411343
);
13421344
}
1345+
1346+
function addExportCommand(cli: Argv<GlobalCliOptions>, cliContext: CliContext) {
1347+
cli.command(
1348+
"export <output-path>",
1349+
"Export your API to an OpenAPI spec",
1350+
(yargs) =>
1351+
yargs
1352+
.positional("output-path", {
1353+
type: "string",
1354+
description: "Path to write the OpenAPI spec",
1355+
demandOption: true
1356+
})
1357+
.option("api", {
1358+
string: true,
1359+
description: "Only run the command on the provided API"
1360+
}),
1361+
async (argv) => {
1362+
await cliContext.instrumentPostHogEvent({
1363+
command: "fern export",
1364+
properties: {
1365+
outputPath: argv.outputPath
1366+
}
1367+
});
1368+
1369+
await generateOpenAPIForWorkspaces({
1370+
project: await loadProjectAndRegisterWorkspacesWithContext(cliContext, {
1371+
commandLineApiWorkspace: argv.api,
1372+
defaultToAllApiWorkspaces: false
1373+
}),
1374+
cliContext,
1375+
outputPath: resolve(cwd(), argv.outputPath)
1376+
});
1377+
}
1378+
);
1379+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { OpenAPIV3 } from "openapi-types";
2+
3+
import {
4+
DeclaredErrorName,
5+
DeclaredTypeName,
6+
ErrorDeclaration,
7+
IntermediateRepresentation,
8+
TypeDeclaration
9+
} from "@fern-api/ir-sdk";
10+
11+
import { convertServices } from "./converters/servicesConverter";
12+
import { convertType } from "./converters/typeConverter";
13+
import { constructEndpointSecurity, constructSecuritySchemes } from "./security";
14+
15+
export type Mode = "stoplight" | "openapi";
16+
17+
export function convertIrToOpenApi({
18+
apiName,
19+
ir,
20+
mode
21+
}: {
22+
apiName: string;
23+
ir: IntermediateRepresentation;
24+
mode: Mode;
25+
}): OpenAPIV3.Document | undefined {
26+
const schemas: Record<string, OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject> = {};
27+
28+
const typesByName: Record<string, TypeDeclaration> = {};
29+
Object.values(ir.types).forEach((typeDeclaration) => {
30+
// convert type to open api schema
31+
const convertedType = convertType(typeDeclaration, ir);
32+
schemas[convertedType.schemaName] = {
33+
title: convertedType.schemaName,
34+
...convertedType.openApiSchema
35+
};
36+
// populates typesByName map
37+
typesByName[getDeclaredTypeNameKey(typeDeclaration.name)] = typeDeclaration;
38+
});
39+
40+
const errorsByName: Record<string, ErrorDeclaration> = {};
41+
Object.values(ir.errors).forEach((errorDeclaration) => {
42+
errorsByName[getErrorTypeNameKey(errorDeclaration.name)] = errorDeclaration;
43+
});
44+
45+
const security = constructEndpointSecurity(ir.auth);
46+
47+
const paths = convertServices({
48+
ir,
49+
httpServices: Object.values(ir.services),
50+
typesByName,
51+
errorsByName,
52+
errorDiscriminationStrategy: ir.errorDiscriminationStrategy,
53+
security,
54+
environments: ir.environments ?? undefined,
55+
mode
56+
});
57+
58+
const info: OpenAPIV3.InfoObject = {
59+
title: ir.apiDisplayName ?? apiName,
60+
version: ""
61+
};
62+
if (ir.apiDocs != null) {
63+
info.description = ir.apiDocs;
64+
}
65+
66+
const openAPISpec: OpenAPIV3.Document = {
67+
openapi: "3.0.1",
68+
info,
69+
paths,
70+
components: {
71+
schemas,
72+
securitySchemes: constructSecuritySchemes(ir.auth)
73+
}
74+
};
75+
76+
if (ir.environments != null && ir.environments.environments.type === "singleBaseUrl") {
77+
openAPISpec.servers = ir.environments.environments.environments.map((environment) => {
78+
return {
79+
url: environment.url,
80+
description:
81+
environment.docs != null
82+
? `${environment.name.originalName} (${environment.docs})`
83+
: environment.name.originalName
84+
};
85+
});
86+
}
87+
88+
return openAPISpec;
89+
}
90+
91+
export function getDeclaredTypeNameKey(declaredTypeName: DeclaredTypeName): string {
92+
return [
93+
...declaredTypeName.fernFilepath.allParts.map((part) => part.originalName),
94+
declaredTypeName.name.originalName
95+
].join("-");
96+
}
97+
98+
export function getErrorTypeNameKey(declaredErrorName: DeclaredErrorName): string {
99+
return [
100+
...declaredErrorName.fernFilepath.allParts.map((part) => part.originalName),
101+
declaredErrorName.name.originalName
102+
].join("-");
103+
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { OpenAPIV3 } from "openapi-types";
2+
3+
import {
4+
DeclaredTypeName,
5+
ExampleInlinedRequestBodyProperty,
6+
ExampleObjectProperty,
7+
NameAndWireValue,
8+
TypeReference
9+
} from "@fern-api/ir-sdk";
10+
11+
import { OpenApiComponentSchema, convertTypeReference, getReferenceFromDeclaredTypeName } from "./typeConverter";
12+
13+
export interface ObjectProperty {
14+
docs: string | undefined;
15+
name: NameAndWireValue;
16+
valueType: TypeReference;
17+
example?: ExampleObjectProperty | ExampleInlinedRequestBodyProperty;
18+
}
19+
20+
export function convertObject({
21+
docs,
22+
properties,
23+
extensions
24+
}: {
25+
docs: string | undefined;
26+
properties: ObjectProperty[];
27+
extensions: DeclaredTypeName[];
28+
}): OpenAPIV3.SchemaObject {
29+
const convertedProperties: Record<string, OpenApiComponentSchema> = {};
30+
const required: string[] = [];
31+
properties.forEach((objectProperty) => {
32+
const convertedObjectProperty = convertTypeReference(objectProperty.valueType);
33+
34+
let example: unknown = undefined;
35+
if (objectProperty.example != null && objectProperty.valueType.type === "primitive") {
36+
example = objectProperty.example.value.jsonExample;
37+
} else if (
38+
objectProperty.example != null &&
39+
objectProperty.valueType.type === "container" &&
40+
objectProperty.valueType.container.type === "list" &&
41+
objectProperty.valueType.container.list.type === "primitive"
42+
) {
43+
example = objectProperty.example.value.jsonExample;
44+
}
45+
46+
convertedProperties[objectProperty.name.wireValue] = {
47+
...convertedObjectProperty,
48+
description: objectProperty.docs ?? undefined,
49+
example
50+
};
51+
const isOptionalProperty =
52+
objectProperty.valueType.type === "container" && objectProperty.valueType.container.type === "optional";
53+
if (!isOptionalProperty) {
54+
required.push(objectProperty.name.wireValue);
55+
}
56+
});
57+
const convertedSchemaObject: OpenAPIV3.SchemaObject = {
58+
type: "object",
59+
description: docs,
60+
properties: convertedProperties
61+
};
62+
if (required.length > 0) {
63+
convertedSchemaObject.required = required;
64+
}
65+
if (extensions.length > 0) {
66+
convertedSchemaObject.allOf = extensions.map((declaredTypeName) => {
67+
return {
68+
$ref: getReferenceFromDeclaredTypeName(declaredTypeName)
69+
};
70+
});
71+
}
72+
return convertedSchemaObject;
73+
}

0 commit comments

Comments
 (0)