Skip to content

Commit

Permalink
feat(cli): fern check now runs simple rules on openapi specs & over…
Browse files Browse the repository at this point in the history
…rides. (#5559)

* feat(cli): `fern check` now runs simple rules on openapi specs & overrides.

* chore: update changelog

* Add complex test fixture and generate snapshots.

* use `toMatchSnapshot` instead.

---------

Co-authored-by: eyw520 <[email protected]>
  • Loading branch information
eyw520 and eyw520 authored Jan 8, 2025
1 parent d1b49ae commit aaf3262
Show file tree
Hide file tree
Showing 37 changed files with 779 additions and 209 deletions.
4 changes: 4 additions & 0 deletions fern/pages/changelogs/cli/2025-01-08.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
## 0.46.23
**`(feat):`** The CLI now validates that method and group name overrides in OpenAPI settings are not duplicated.


1 change: 1 addition & 0 deletions packages/cli/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"@fern-api/openapi-ir": "workspace:*",
"@fern-api/openapi-ir-parser": "workspace:*",
"@fern-api/openapi-ir-to-fern": "workspace:*",
"@fern-api/oss-validator": "workspace:*",
"@fern-api/posthog-manager": "workspace:*",
"@fern-api/project-loader": "workspace:*",
"@fern-api/register": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { OSSWorkspace } from "@fern-api/lazy-fern-workspace";
import { validateOSSWorkspace } from "@fern-api/oss-validator";
import { TaskContext } from "@fern-api/task-context";

import { logViolations } from "./logViolations";

export async function validateOSSWorkspaceWithoutExiting({
workspace,
context,
logWarnings,
logSummary = true
}: {
workspace: OSSWorkspace;
context: TaskContext;
logWarnings: boolean;
logSummary?: boolean;
}): Promise<{ hasErrors: boolean }> {
const violations = await validateOSSWorkspace(workspace, context);
const { hasErrors } = logViolations({ violations, context, logWarnings, logSummary });

return { hasErrors };
}

export async function validateOSSWorkspaceAndLogIssues({
workspace,
context,
logWarnings
}: {
workspace: OSSWorkspace;
context: TaskContext;
logWarnings: boolean;
}): Promise<void> {
const { hasErrors } = await validateOSSWorkspaceWithoutExiting({
workspace,
context,
logWarnings
});

if (hasErrors) {
context.failAndThrow();
}
}
7 changes: 7 additions & 0 deletions packages/cli/cli/src/commands/validate/validateWorkspaces.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { OSSWorkspace } from "@fern-api/lazy-fern-workspace";
import { Project } from "@fern-api/project-loader";

import { CliContext } from "../../cli-context/CliContext";
import { validateAPIWorkspaceAndLogIssues } from "./validateAPIWorkspaceAndLogIssues";
import { validateDocsWorkspaceAndLogIssues } from "./validateDocsWorkspaceAndLogIssues";
import { validateOSSWorkspaceAndLogIssues } from "./validateOSSWorkspaceAndLogIssues";

export async function validateWorkspaces({
project,
Expand All @@ -27,6 +29,11 @@ export async function validateWorkspaces({

await Promise.all(
project.apiWorkspaces.map(async (workspace) => {
if (workspace instanceof OSSWorkspace) {
await cliContext.runTaskForWorkspace(workspace, async (context) => {
await validateOSSWorkspaceAndLogIssues({ workspace, context, logWarnings });
});
}
await cliContext.runTaskForWorkspace(workspace, async (context) => {
const fernWorkspace = await workspace.toFernWorkspace({ context });
await validateAPIWorkspaceAndLogIssues({ workspace: fernWorkspace, context, logWarnings });
Expand Down
3 changes: 3 additions & 0 deletions packages/cli/cli/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,9 @@
{
"path": "../fern-definition/validator"
},
{
"path": "../workspace/oss-validator"
},
{
"path": "../cli-migrations"
},
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/cli/versions.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
- changelogEntry:
- summary: |
The CLI now validates that method and group name overrides in OpenAPI settings are not duplicated.
type: feat
irVersion: 53
version: 0.46.23

- changelogEntry:
- summary: |
Support configuration of Google Analytics and Google Tag Manager in API Docs.
Expand Down
10 changes: 5 additions & 5 deletions packages/cli/workspace/lazy-fern-workspace/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,17 @@
"@fern-api/api-workspace-commons": "workspace:*",
"@fern-api/cli-logger": "workspace:*",
"@fern-api/configuration-loader": "workspace:*",
"@fern-api/conjure-to-fern": "workspace:*",
"@fern-api/core": "workspace:*",
"@fern-api/core-utils": "workspace:*",
"@fern-api/fern-definition-schema": "workspace:*",
"@fern-api/fs-utils": "workspace:*",
"@fern-api/logger": "workspace:*",
"@fern-api/logging-execa": "workspace:*",
"@fern-api/openapi-ir": "workspace:*",
"@fern-api/openapi-ir-parser": "workspace:*",
"@fern-api/semver-utils": "workspace:*",
"@fern-api/task-context": "workspace:*",
"@fern-api/fern-definition-schema": "workspace:*",
"@fern-api/conjure-to-fern": "workspace:*",
"@fern-fern/fiddle-sdk": "0.0.584",
"@redocly/openapi-core": "^1.4.1",
"@types/uuid": "^9.0.8",
Expand All @@ -57,6 +57,7 @@
"zod": "^3.22.3"
},
"devDependencies": {
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
"@types/jest": "^29.5.14",
"@types/js-yaml": "^4.0.8",
"@types/lodash-es": "^4.17.12",
Expand All @@ -66,9 +67,8 @@
"@types/tar": "^6.1.11",
"depcheck": "^1.4.7",
"eslint": "^9.16.0",
"vitest": "^2.1.8",
"@trivago/prettier-plugin-sort-imports": "^5.2.1",
"prettier": "^3.4.2",
"typescript": "5.7.2"
"typescript": "5.7.2",
"vitest": "^2.1.8"
}
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
import { Source, bundle } from "@redocly/openapi-core";
import { readFile } from "fs/promises";
import yaml from "js-yaml";
import { OpenAPI, OpenAPIV2, OpenAPIV3 } from "openapi-types";
import { convertObj } from "swagger2openapi";

import { DEFAULT_OPENAPI_BUNDLE_OPTIONS, OpenAPISpec, isOpenAPIV2, isOpenAPIV3 } from "@fern-api/api-workspace-commons";
import { AbsoluteFilePath, RelativeFilePath, dirname, join, relative } from "@fern-api/fs-utils";
import { OpenAPISpec, isOpenAPIV2, isOpenAPIV3 } from "@fern-api/api-workspace-commons";
import { AbsoluteFilePath, join, relative } from "@fern-api/fs-utils";
import { Source as OpenApiIrSource } from "@fern-api/openapi-ir";
import { AsyncAPIV2, Document, FernOpenAPIExtension } from "@fern-api/openapi-ir-parser";
import { Document } from "@fern-api/openapi-ir-parser";
import { TaskContext } from "@fern-api/task-context";

import { mergeWithOverrides } from "./mergeWithOverrides";
import { convertOpenAPIV2ToV3 } from "../utils/convertOpenAPIV2ToV3";
import { loadAsyncAPI } from "../utils/loadAsyncAPI";
import { loadOpenAPI } from "../utils/loadOpenAPI";

export class OpenAPILoader {
constructor(private readonly absoluteFilePath: AbsoluteFilePath) {}
Expand All @@ -34,7 +32,7 @@ export class OpenAPILoader {
? OpenApiIrSource.protobuf({ file: sourceRelativePath })
: OpenApiIrSource.openapi({ file: sourceRelativePath });
if (contents.includes("openapi") || contents.includes("swagger")) {
const openAPI = await this.loadOpenAPI({
const openAPI = await loadOpenAPI({
absolutePathToOpenAPI: spec.absoluteFilepath,
context,
absolutePathToOpenAPIOverrides: spec.absoluteFilepathToOverrides
Expand All @@ -48,7 +46,7 @@ export class OpenAPILoader {
settings: spec.settings
});
} else if (isOpenAPIV2(openAPI)) {
const convertedOpenAPI = await this.convertOpenAPIV2ToV3(openAPI);
const convertedOpenAPI = await convertOpenAPIV2ToV3(openAPI);
documents.push({
type: "openapi",
value: convertedOpenAPI,
Expand All @@ -58,7 +56,7 @@ export class OpenAPILoader {
});
}
} else if (contents.includes("asyncapi")) {
const asyncAPI = await this.loadAsyncAPI({
const asyncAPI = await loadAsyncAPI({
context,
absoluteFilePath: spec.absoluteFilepath,
absoluteFilePathToOverrides: spec.absoluteFilepathToOverrides
Expand All @@ -76,97 +74,4 @@ export class OpenAPILoader {
}
return documents;
}

private async loadOpenAPI({
context,
absolutePathToOpenAPI,
absolutePathToOpenAPIOverrides
}: {
context: TaskContext;
absolutePathToOpenAPI: AbsoluteFilePath;
absolutePathToOpenAPIOverrides: AbsoluteFilePath | undefined;
}): Promise<OpenAPI.Document> {
const parsed = await this.parseOpenAPI({
absolutePathToOpenAPI
});

let overridesFilepath = undefined;
if (absolutePathToOpenAPIOverrides != null) {
overridesFilepath = absolutePathToOpenAPIOverrides;
} else if (
typeof parsed === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(parsed as any)[FernOpenAPIExtension.OPENAPI_OVERIDES_FILEPATH] != null
) {
overridesFilepath = join(
dirname(absolutePathToOpenAPI),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
RelativeFilePath.of((parsed as any)[FernOpenAPIExtension.OPENAPI_OVERIDES_FILEPATH])
);
}

if (overridesFilepath != null) {
const merged = await mergeWithOverrides<OpenAPI.Document>({
absoluteFilePathToOverrides: overridesFilepath,
context,
data: parsed
});
// Run the merged document through the parser again to ensure that any override
// references are resolved.
return await this.parseOpenAPI({
absolutePathToOpenAPI,
parsed: merged
});
}
return parsed;
}

private async loadAsyncAPI({
context,
absoluteFilePath,
absoluteFilePathToOverrides
}: {
context: TaskContext;
absoluteFilePath: AbsoluteFilePath;
absoluteFilePathToOverrides: AbsoluteFilePath | undefined;
}): Promise<AsyncAPIV2.Document> {
const contents = (await readFile(absoluteFilePath)).toString();
const parsed = (await yaml.load(contents)) as AsyncAPIV2.Document;
if (absoluteFilePathToOverrides != null) {
return await mergeWithOverrides<AsyncAPIV2.Document>({
absoluteFilePathToOverrides,
context,
data: parsed
});
}
return parsed;
}

private async parseOpenAPI({
absolutePathToOpenAPI,
parsed
}: {
absolutePathToOpenAPI: AbsoluteFilePath;
parsed?: OpenAPI.Document;
}): Promise<OpenAPI.Document> {
const result =
parsed != null
? await bundle({
...DEFAULT_OPENAPI_BUNDLE_OPTIONS,
doc: {
source: new Source(absolutePathToOpenAPI, "<openapi>"),
parsed
}
})
: await bundle({
...DEFAULT_OPENAPI_BUNDLE_OPTIONS,
ref: absolutePathToOpenAPI
});
return result.bundle.parsed;
}

private async convertOpenAPIV2ToV3(openAPI: OpenAPIV2.Document): Promise<OpenAPIV3.Document> {
const conversionResult = await convertObj(openAPI, {});
return conversionResult.openapi;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { OpenAPIV2, OpenAPIV3 } from "openapi-types";
import { convertObj } from "swagger2openapi";

export async function convertOpenAPIV2ToV3(openAPI: OpenAPIV2.Document): Promise<OpenAPIV3.Document> {
const conversionResult = await convertObj(openAPI, {});
return conversionResult.openapi;
}
3 changes: 3 additions & 0 deletions packages/cli/workspace/lazy-fern-workspace/src/utils/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { getAllOpenAPISpecs } from "./getAllOpenAPISpecs";
export { loadDependency } from "./loadDependency";
export { WorkspaceLoaderFailureType, type WorkspaceLoader } from "./Result";
export { convertOpenAPIV2ToV3 } from "./convertOpenAPIV2ToV3";
export { loadAsyncAPI } from "./loadAsyncAPI";
export { loadOpenAPI } from "./loadOpenAPI";
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { readFile } from "fs/promises";
import yaml from "js-yaml";

import { AbsoluteFilePath } from "@fern-api/fs-utils";
import { AsyncAPIV2 } from "@fern-api/openapi-ir-parser";
import { TaskContext } from "@fern-api/task-context";

import { mergeWithOverrides } from "../loaders/mergeWithOverrides";

export async function loadAsyncAPI({
context,
absoluteFilePath,
absoluteFilePathToOverrides
}: {
context: TaskContext;
absoluteFilePath: AbsoluteFilePath;
absoluteFilePathToOverrides: AbsoluteFilePath | undefined;
}): Promise<AsyncAPIV2.Document> {
const contents = (await readFile(absoluteFilePath)).toString();
const parsed = (await yaml.load(contents)) as AsyncAPIV2.Document;
if (absoluteFilePathToOverrides != null) {
return await mergeWithOverrides<AsyncAPIV2.Document>({
absoluteFilePathToOverrides,
context,
data: parsed
});
}
return parsed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { OpenAPI } from "openapi-types";

import { AbsoluteFilePath, RelativeFilePath, dirname, join } from "@fern-api/fs-utils";
import { FernOpenAPIExtension } from "@fern-api/openapi-ir-parser";
import { TaskContext } from "@fern-api/task-context";

import { mergeWithOverrides } from "../loaders/mergeWithOverrides";
import { parseOpenAPI } from "./parseOpenAPI";

export async function loadOpenAPI({
context,
absolutePathToOpenAPI,
absolutePathToOpenAPIOverrides
}: {
context: TaskContext;
absolutePathToOpenAPI: AbsoluteFilePath;
absolutePathToOpenAPIOverrides: AbsoluteFilePath | undefined;
}): Promise<OpenAPI.Document> {
const parsed = await parseOpenAPI({
absolutePathToOpenAPI
});

let overridesFilepath = undefined;
if (absolutePathToOpenAPIOverrides != null) {
overridesFilepath = absolutePathToOpenAPIOverrides;
} else if (
typeof parsed === "object" &&
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(parsed as any)[FernOpenAPIExtension.OPENAPI_OVERIDES_FILEPATH] != null
) {
overridesFilepath = join(
dirname(absolutePathToOpenAPI),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
RelativeFilePath.of((parsed as any)[FernOpenAPIExtension.OPENAPI_OVERIDES_FILEPATH])
);
}

if (overridesFilepath != null) {
const merged = await mergeWithOverrides<OpenAPI.Document>({
absoluteFilePathToOverrides: overridesFilepath,
context,
data: parsed
});
// Run the merged document through the parser again to ensure that any override
// references are resolved.
return await parseOpenAPI({
absolutePathToOpenAPI,
parsed: merged
});
}
return parsed;
}
Loading

0 comments on commit aaf3262

Please sign in to comment.