Skip to content

Commit

Permalink
Merge pull request #17 from jfrconley/joco/openapi-spec-gen
Browse files Browse the repository at this point in the history
OpenAPI 3 spec generation
  • Loading branch information
jfrconley authored Dec 21, 2023
2 parents 15aa946 + 6f5cd97 commit 0ab1bc5
Show file tree
Hide file tree
Showing 47 changed files with 3,946 additions and 764 deletions.
5 changes: 5 additions & 0 deletions .changeset/witty-teachers-smoke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nornir/rest": major
---

OpenAPI 3 spec generation
15 changes: 1 addition & 14 deletions eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,21 +18,16 @@ const test = {
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:unicorn/recommended",
"plugin:jest/recommended",
"plugin:jest/style",
"plugin:workspaces/recommended",
"plugin:eslint-comments/recommended",
"plugin:sonarjs/recommended",
],
parser: "@typescript-eslint/parser",
plugins: [
"@typescript-eslint",
"eslint-plugin-workspaces",
"eslint-plugin-unicorn",
"eslint-plugin-jest",
"eslint-plugin-eslint-comments",
"eslint-plugin-sonarjs",
"eslint-plugin-no-secrets",
],
root: true,
Expand All @@ -44,15 +39,8 @@ const test = {
],
rules: {
// Reduce is confusing, but it shouldn't be banned
"unicorn/no-array-reduce": ["off"],
"unicorn/filename-case": ["error", {
case: "kebabCase",
}],
"unicorn/no-empty-files": ["off"],
"workspaces/require-dependency": ["off"],
"unicorn/prevent-abbreviations": ["off"],
"no-secrets/no-secrets": ["warn", {"tolerance": 5.0}],
"unicorn/numeric-separators-style": ["off"],
"@typescript-eslint/no-unused-vars": ["warn", {
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
Expand All @@ -72,8 +60,7 @@ const test = {
"match": false
}
},
],
"sonarjs/no-duplicate-string": ["off"],
]
},
};

Expand Down
6 changes: 2 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,6 @@
"eslint-plugin-eslint-comments": "^3.2.0",
"eslint-plugin-jest": "^27.2.3",
"eslint-plugin-no-secrets": "^0.8.9",
"eslint-plugin-sonarjs": "^0.19.0",
"eslint-plugin-unicorn": "^48.0.0",
"eslint-plugin-workspaces": "^0.9.0",
"husky": "^8.0.3",
"jest": "^29.5.0",
Expand All @@ -29,9 +27,9 @@
"plop": "^3.1.2",
"scripts": "workspace:^",
"syncpack": "^9.8.4",
"ts-patch": "^3.0.2",
"ts-patch": "^3.1.1",
"turbo": "^1.9.2",
"typescript": "^5.2.2"
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0",
Expand Down
6 changes: 3 additions & 3 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@
"author": "John Conley",
"devDependencies": {
"@jest/globals": "^29.5.0",
"@nrfcloud/ts-json-schema-transformer": "^1.2.4",
"@nrfcloud/ts-json-schema-transformer": "^1.3.0",
"@types/jest": "^29.4.0",
"@types/node": "^18.15.11",
"esbuild": "^0.17.18",
"eslint": "^8.45.0",
"jest": "^29.5.0",
"ts-patch": "^3.0.2",
"typescript": "^5.2.2"
"ts-patch": "^3.1.1",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0",
Expand Down
44 changes: 21 additions & 23 deletions packages/rest/__tests__/src/routing.spec.mts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import {
AnyMimeType,
Controller,
GetChain,
HttpEvent,
HttpRequest,
HttpStatusCode,
MimeType,
HttpRequestEmpty,
normalizeEventHeaders,
NornirRestRequestValidationError,
PostChain,
router
router,
MimeType,
HttpStatusCode
} from "../../dist/runtime/index.mjs";
import {nornir, Nornir} from "@nornir/core";
import {describe} from "@jest/globals";
import {NornirRouteNotFoundError} from "../../dist/runtime/router.mjs";

interface RouteGetInput extends HttpRequest {
interface RouteGetInput extends HttpRequestEmpty {
}


Expand Down Expand Up @@ -80,42 +80,41 @@ class TestController {
* @summary Cool Route
*/
@GetChain("/route")
public getRoute(chain: Nornir<RouteGetInput>) {
public getRoute(chain: Nornir<RouteGetInput>): Nornir<RouteGetInput, {statusCode: HttpStatusCode.Ok, body: string, headers: {"content-type": MimeType.TextPlain}}> {
return chain
.use(console.log)
.use(() => ({
statusCode: HttpStatusCode.Ok,
body: `cool`,
headers: {
// eslint-disable-next-line sonarjs/no-duplicate-string
"content-type": MimeType.TextPlain,
"content-type": MimeType.TextPlain
},
}));
}

@GetChain("/route2")
public getEmptyRoute(chain: Nornir<RouteGetInput>) {
public getEmptyRoute(chain: Nornir<RouteGetInput>): Nornir<RouteGetInput, {statusCode: HttpStatusCode.Ok, headers: NonNullable<unknown>}> {
return chain
.use(() => ({
statusCode: HttpStatusCode.Ok,
body: undefined,
headers: {
"content-type": AnyMimeType
},
}));
.use(() => {
return {
statusCode: HttpStatusCode.Ok,
body: undefined,
headers: {},
}
});
}

@PostChain("/route")
public postRoute(chain: Nornir<RoutePostInput>) {
public postRoute(chain: Nornir<RoutePostInput>): Nornir<RoutePostInput, {statusCode: HttpStatusCode.Ok, body: string, headers: {"content-type": MimeType.TextPlain}}> {
return chain
.use(input => input.headers["content-type"])
.use(contentType => ({
statusCode: HttpStatusCode.Ok,
body: `Content-Type: ${contentType}`,
headers: {
"content-type": MimeType.TextPlain,
"content-type": MimeType.TextPlain
},
}));
} as const));
}
}

Expand All @@ -134,7 +133,7 @@ describe("REST tests", () => {
headers: {},
query: {}
});
expect(response.statusCode).toEqual(HttpStatusCode.Ok);
expect(response.statusCode).toEqual("200");
expect(response.body).toBe("cool");
expect(response.headers["content-type"]).toBe("text/plain");
})
Expand All @@ -154,7 +153,7 @@ describe("REST tests", () => {
}
});
expect(response).toEqual({
statusCode: HttpStatusCode.Ok,
statusCode: "200",
body: "Content-Type: application/json",
headers: {
"content-type": "text/plain"
Expand All @@ -170,10 +169,9 @@ describe("REST tests", () => {
query: {}
});
expect(response).toEqual({
statusCode: HttpStatusCode.Ok,
statusCode: "200",
body: undefined,
headers: {
"content-type": AnyMimeType
}
})
})
Expand Down
33 changes: 27 additions & 6 deletions packages/rest/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,39 @@
"name": "@nornir/rest",
"description": "A nornir library",
"version": "1.5.2",
"bin": {
"nornir-oas": "./dist/cli/cli.js"
},
"dependencies": {
"@apidevtools/json-schema-ref-parser": "^11.1.0",
"@nornir/core": "workspace:^",
"@nrfcloud/ts-json-schema-transformer": "^1.2.4",
"@nrfcloud/ts-json-schema-transformer": "^1.3.0",
"@types/aws-lambda": "^8.10.115",
"ajv": "^8.12.0",
"atlassian-openapi": "^1.0.18",
"glob": "^10.3.10",
"json-schema-traverse": "^1.0.0",
"lodash": "^4.17.21",
"openapi-diff": "^0.23.6",
"openapi-types": "^12.1.0",
"trouter": "^3.2.1",
"ts-json-schema-generator": "^1.4.0",
"ts-morph": "^19.0.0",
"tsutils": "^3.21.0"
"ts-is-present": "^1.2.2",
"ts-json-schema-generator": "^1.5.0",
"ts-morph": "^21.0.1",
"tsutils": "^3.21.0",
"yargs": "^17.7.2"
},
"devDependencies": {
"@jest/globals": "^29.5.0",
"@types/jest": "^29.4.0",
"@types/json-schema": "^7.0.15",
"@types/lodash": "^4.14.202",
"@types/node": "^18.15.11",
"@types/yargs": "^17.0.32",
"eslint": "^8.45.0",
"jest": "^29.5.0",
"ts-patch": "^3.0.2",
"typescript": "^5.2.2"
"ts-patch": "^3.1.1",
"typescript": "^5.3.3"
},
"engines": {
"node": ">=18.0.0",
Expand Down Expand Up @@ -68,5 +82,12 @@
"prepare": "patch-typescript",
"prepublish": "pnpm build:clean",
"test": "pnpm tests"
},
"tsp": {
"name": "@nornir/rest",
"transform": "./dist/transform/transform.js",
"tscOptions": {
"parseAllJsDoc": true
}
}
}
42 changes: 42 additions & 0 deletions packages/rest/src/cli/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { readFileSync, writeFileSync } from "fs";
import { merge } from "lodash";
import path from "path";
import yargs from "yargs";
import { isErrorResult } from "../transform/openapi-merge";
import { getMergedSpec } from "./lib/collect";
import { resolveTsConfigOutdir } from "./lib/ts-utils";

yargs
.scriptName("nornir-oas")
.command("collect", "Build OpenAPI spec from collected spec files", args =>
args
.option("output", {
default: path.join(process.cwd(), "openapi.json"),
alias: "o",
type: "string",
description: "Output file for the generated OpenAPI spec",
})
.option("scanDirectory", {
default: resolveTsConfigOutdir() ?? path.join(process.cwd(), "dist"),
type: "string",
alias: "s",
description:
"Directory to scan for spec files. This is probably the directory where your compiled typescript files are.",
})
.option("overrideSpec", {
type: "string",
alias: "b",
description: "Path to an openapi JSON file to deeply merge with collected specification",
}), args => {
const mergedSpec = getMergedSpec(args.scanDirectory);
if (isErrorResult(mergedSpec)) {
throw new Error("Failed to merge spec files");
}
let spec = mergedSpec.output;
if (args.overrideSpec) {
const overrideSpec = JSON.parse(readFileSync(args.overrideSpec, "utf-8"));
spec = merge(spec, overrideSpec);
}

writeFileSync(args.output, JSON.stringify(spec, null, 2));
}).strictCommands().demandCommand().parse();
30 changes: 30 additions & 0 deletions packages/rest/src/cli/lib/collect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Swagger } from "atlassian-openapi";
import { readFileSync } from "fs";
import { sync } from "glob";
import { OpenAPIV3_1 } from "openapi-types";
import { merge } from "../../transform/openapi-merge/index.js";
import SwaggerV3 = Swagger.SwaggerV3;

export function getSpecFiles(scanDir: string) {
return sync(`${scanDir}/**/*.nornir.oas.json`);
}

export function readSpecFiles(paths: string[]) {
return paths.map(path => {
return JSON.parse(readFileSync(path, "utf-8")) as OpenAPIV3_1.Document;
});
}

export function getMergedSpec(scanDir: string) {
const files = getSpecFiles(scanDir);
const specs = readSpecFiles(files);
return merge(specs.map(spec => ({
dispute: {
// mergeDispute: true,
// alwaysApply: true,
// prefix: "",
// suffix: ""
},
oas: spec as SwaggerV3,
})));
}
24 changes: 24 additions & 0 deletions packages/rest/src/cli/lib/ts-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { dirname, resolve } from "node:path";
import ts from "typescript";

export function resolveTsConfigOutdir(searchPath = process.cwd(), configName = "tsconfig.json") {
const path = ts.findConfigFile(searchPath, ts.sys.fileExists, configName);
if (path == undefined) {
return;
}
const config = ts.readConfigFile(path, ts.sys.readFile);
if (config.error) {
return;
}
const compilerOptions = config.config as { compilerOptions: ts.CompilerOptions };

const pathDir = dirname(path);

const outDir = compilerOptions.compilerOptions.outDir;

if (outDir == undefined) {
return undefined;
}

return resolve(pathDir, outDir);
}
Loading

0 comments on commit 0ab1bc5

Please sign in to comment.