Skip to content

Commit 1e4d7f5

Browse files
committed
added support to save openapi spec to disk
Signed-off-by: Noorain Panjwani <[email protected]>
1 parent 0289d45 commit 1e4d7f5

11 files changed

+175
-46
lines changed

.eslintrc.json

+1-4
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,7 @@
2424
"@typescript-eslint/semi": "error",
2525
"@typescript-eslint/no-var-requires": "error",
2626
"@typescript-eslint/member-delimiter-style": "error",
27-
"quotes": [
28-
"error",
29-
"double"
30-
],
27+
"quotes": ["error", "double"],
3128
"@typescript-eslint/no-unused-vars": [
3229
"error",
3330
{

.gitignore

+5
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,8 @@ dist
128128
.yarn/build-state.yml
129129
.yarn/install-state.gz
130130
.pnp.*
131+
132+
# Autogenerated config files
133+
openapi.json
134+
openapi.yaml
135+
sc.yaml

README.md

+17-2
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ Typescript API for Space Cloud
33

44
## Worker API
55

6-
Sample usage
6+
### Sample usage
77
```ts
88
import { Server } from "@spacecloud-io/worker";
99
import { z } from "zod";
@@ -21,7 +21,7 @@ const router = server.router();
2121
// Register a query object.
2222
router.query("operate") // `opId` is the name of the operation
2323
.method("GET") // Defaults to `GET` for queries and `POST` for mutations
24-
.url("/v1/operate") // Defaults to `${baseURL}/${opId}`
24+
.url("/v1/operate") // Defaults to `${baseUrl}/${opId}`
2525
.input(z.object({ name: z.string() }))
2626
.output(z.object({ greeting: z.string() }))
2727
.fn(async (_ctx, req) => {
@@ -30,4 +30,19 @@ router.query("operate") // `opId` is the name of the opera
3030

3131
// Start the express http server.
3232
server.start();
33+
```
34+
35+
The worker generates the some additional routes as shown below:
36+
37+
```
38+
# Routes created to expose the OpenAPI specification generated
39+
[GET] `${baseUrl}/openapi.json`
40+
[GET] `${baseUrl}/openapi.yaml`
41+
```
42+
43+
### Saving OpenAPI specification to disk
44+
45+
Simply add the flag `--save-openapi` flag to save the autogenerated Open API specification in yaml format. Use the `-f, --file <path>` flag to control the file path. The specification will be persisted at `${file}/openapi.yaml`.
46+
```
47+
node index.js --save-openapi -f ./config
3348
```

package.json

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"main": "index.js",
77
"scripts": {
88
"build:worker": "pnpm --filter worker run build",
9+
"build:basic": "pnpm --filter basic run build",
910
"dev:worker": "pnpm --filter worker run dev",
1011
"dev:basic": "pnpm --filter basic run dev",
1112
"start:basic": "pnpm --filter basic run start"

packages/worker/package.json

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@spacecloud-io/worker",
3-
"version": "0.1.2",
3+
"version": "0.1.3",
44
"description": "",
55
"main": "./dist/index.js",
66
"types": "./dist/index.d.ts",
@@ -13,8 +13,10 @@
1313
"author": "Noorain Panjwani <[email protected]>",
1414
"license": "Apache-2.0",
1515
"dependencies": {
16+
"commander": "^11.0.0",
1617
"express": "^4.18.2",
1718
"openapi3-ts": "^4.1.2",
19+
"yaml": "^2.3.2",
1820
"zod": "^3.21.4",
1921
"zod-to-json-schema": "^3.21.1"
2022
},

packages/worker/src/config.ts

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import fs from "fs";
2+
import { stringify } from "yaml";
3+
4+
export const generateSCConfigFile = (path: string, spec: any) => {
5+
const data = stringify(spec);
6+
fs.writeFileSync(path, data);
7+
};
8+
9+
export const createOpenApiSpec = (spec: any, name: string, port: number) => {
10+
return {
11+
apiVersion: "core.space-cloud.io/v1alpha1",
12+
kind: "OpenAPISource",
13+
metadata: {
14+
name: name
15+
},
16+
spec: {
17+
source: { url: `http://localhost:${port}` },
18+
openapi: { value: spec }
19+
}
20+
};
21+
};

packages/worker/src/helpers.ts

+30
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,36 @@
11
import { Request } from "express";
22
import openapi3 from "openapi3-ts/oas30";
33

4+
export const getOpenApiParametersFromJsonSchema = (schema: any) => {
5+
let params: openapi3.ParameterObject[] = [];
6+
7+
for (const key in schema.properties) {
8+
const paramType = schema.properties[key].type;
9+
switch (paramType) {
10+
case "string":
11+
case "number":
12+
case "integer":
13+
case "boolean":
14+
params = [...params, {
15+
in: "query",
16+
name: key,
17+
schema: { type: paramType },
18+
}];
19+
break;
20+
21+
default:
22+
params = [...params, {
23+
in: "query",
24+
name: key,
25+
content: { "application/json": { schema: schema.properties[key] } },
26+
}];
27+
break;
28+
}
29+
}
30+
31+
return params;
32+
};
33+
434
export const getPayloadFromParams = (query: any, inputSchema: any) => {
535
// Return the payload as is if type is any or additional property is set to true
636
if (inputSchema.type === undefined || inputSchema.additionalProperties) return query;

packages/worker/src/route.ts

+31-25
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { z } from "zod";
33
import { zodToJsonSchema } from "zod-to-json-schema";
44
import openapi3 from "openapi3-ts/oas30";
55

6-
import { getErrorResponseSchema, getPayloadFromParams, isZodTypeNull, processException } from "./helpers";
6+
import { getErrorResponseSchema, getOpenApiParametersFromJsonSchema, getPayloadFromParams, isZodTypeNull, processException } from "./helpers";
77
import { Context } from "./context";
88

99
export type RouteUpdater = (r: Route<any, any>) => void;
@@ -118,18 +118,38 @@ export class Route<I, O> {
118118
}
119119

120120
public _getOpenAPIOperation() {
121-
// Process request schema
122-
const isRequestNull = isZodTypeNull(this.config.zodSchemas.input);
123-
const requestBodyObject: openapi3.RequestBodyObject = {
124-
description: `Request object for ${this.config.opId}`,
125-
content: {},
126-
required: false
121+
// First create a operation
122+
const operation: openapi3.OperationObject = {
123+
operationId: this.config.opId,
124+
responses: {
125+
"400": getErrorResponseSchema(),
126+
"401": getErrorResponseSchema(),
127+
"403": getErrorResponseSchema(),
128+
"500": getErrorResponseSchema(),
129+
},
130+
131+
// Add the sc specific extensions
132+
["x-request-op-type"]: this.config.opType,
127133
};
128134

129-
// Add request schema if we require payload
135+
// Process request schema
136+
const isRequestNull = isZodTypeNull(this.config.zodSchemas.input);
130137
if (!isRequestNull) {
131-
requestBodyObject.content = { "application/json": { schema: this.config.jsonSchema.input } };
132-
requestBodyObject.required = true;
138+
switch (this.config.method) {
139+
case "get":
140+
case "delete":
141+
operation.parameters = getOpenApiParametersFromJsonSchema(this.config.jsonSchema.input);
142+
break;
143+
144+
default:
145+
// Add request body if we require payload
146+
operation.requestBody = {
147+
description: `Request object for ${this.config.opId}`,
148+
content: { "application/json": { schema: this.config.jsonSchema.input } },
149+
required: true
150+
};
151+
break;
152+
}
133153
}
134154

135155
// Process response schemas
@@ -141,21 +161,7 @@ export class Route<I, O> {
141161
if (!isResponseNull) {
142162
successResponseObject.content = { "application/json": { schema: this.config.jsonSchema.output } };
143163
}
144-
145-
146-
// Add the schemas to operation
147-
const operation: openapi3.OperationObject = {
148-
operationId: this.config.opId,
149-
requestBody: requestBodyObject,
150-
responses: {
151-
[successResponseStatusCode]: successResponseObject,
152-
"400": getErrorResponseSchema(),
153-
"500": getErrorResponseSchema(),
154-
},
155-
156-
// Add the sc specific extensions
157-
["x-request-op-type"]: this.config.opType,
158-
};
164+
operation.responses[successResponseStatusCode] = successResponseObject;
159165

160166
return {
161167
path: this.config.url,

packages/worker/src/router.ts

+13-6
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ export interface RouterConfig {
1212
export class Router {
1313
private config: RouterConfig;
1414
private routes: Route<any, any>[];
15+
openApiBuilder: openapi3.OpenApiBuilder;
1516

1617
constructor(config: RouterConfig) {
1718
this.config = config;
1819
this.routes = [];
20+
this.openApiBuilder = openapi3.OpenApiBuilder.create();
1921
}
2022

2123
/**
@@ -82,23 +84,28 @@ export class Router {
8284
return route;
8385
}
8486

85-
_initialiseRoutes(app: express.Application) {
87+
_generateOpenApi() {
8688
// Create the open api document
87-
const builder = openapi3.OpenApiBuilder.create();
88-
builder.addTitle(this.config.name);
89+
this.openApiBuilder.addTitle(this.config.name);
8990

9091
// Add one path for each operation
9192
this.routes.forEach(route => {
9293
// Get OpenAPI operation for route
9394
const { path, method, operation } = route._getOpenAPIOperation();
9495

9596
// Add path to OpenAPI spec
96-
builder.addPath(path, { [method]: operation });
97+
this.openApiBuilder.addPath(path, { [method]: operation });
9798
});
99+
}
98100

101+
_getOpenApiSpec() {
102+
return this.openApiBuilder.getSpec();
103+
}
104+
105+
_initialiseRoutes(app: express.Application) {
99106
// Add express routes for openapi doc
100-
const jsonSpec = builder.getSpecAsJson();
101-
const yamlSpec = builder.getSpecAsYaml();
107+
const jsonSpec = this.openApiBuilder.getSpecAsJson();
108+
const yamlSpec = this.openApiBuilder.getSpecAsYaml();
102109
app.get(`${this.config.baseUrl}/openapi.json`, (_req, res) => {
103110
res.setHeader("Content-Type", "application/json");
104111
res.status(200).send(jsonSpec);

packages/worker/src/server.ts

+38-5
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import express, { Application } from "express";
2+
import { Command } from "commander";
23
import { Router } from "./router";
4+
import { generateSCConfigFile } from "./config";
35

46
export interface ServerConfig {
57
name: string;
@@ -11,16 +13,31 @@ export class Server {
1113
private config: ServerConfig;
1214
private app: express.Application;
1315
private routerObj: Router;
16+
private program: Command;
1417

1518
public constructor(config: ServerConfig) {
16-
// Set default values if not provided
17-
if (!config.baseUrl) config.baseUrl = "/v1";
18-
if (!config.port) config.port = 3000;
1919

2020
// Initialise the server object
2121
this.config = config;
22-
this.routerObj = new Router({ name: config.name, baseUrl: config.baseUrl });
22+
23+
// Set default values if not provided
24+
if (!this.config.baseUrl) this.config.baseUrl = "/v1";
25+
if (!this.config.port) this.config.port = 3000;
26+
27+
// Create a router object
28+
this.routerObj = new Router({ name: this.config.name, baseUrl: this.config.baseUrl });
29+
30+
// Create an express app
2331
this.app = express();
32+
33+
// Create an a commander object
34+
this.program = new Command();
35+
this.program
36+
.name(this.config.name)
37+
.description(`"${this.config.name}" is a SpaceCloud worker`);
38+
39+
this.program.option("--save-openapi", "Save the autogenerated OpenAPI configuration", false);
40+
this.program.option("-f, --file <path>", "File path to store the configuration.", ".");
2441
}
2542

2643
/**
@@ -49,7 +66,23 @@ export class Server {
4966
* It also exposes the OpenAPI spec for this server on `${baseUrl}/openapi.json`.
5067
*/
5168
public start() {
52-
// First add the global middlewares we need
69+
// Generate the open api spec for the app
70+
this.routerObj._generateOpenApi();
71+
72+
// Parse the commands passed
73+
this.program.parse();
74+
const options = this.program.opts();
75+
76+
// Check if the `--save-openapi` flag has been provided
77+
if (options["saveOpenapi"]) {
78+
const path = `${options["file"]}/openapi.yaml`;
79+
console.log(`Generating OpenAPI file at path - "${path}"`);
80+
generateSCConfigFile(path, this.routerObj._getOpenApiSpec());
81+
console.log("Open API configuration has been successfully saved!");
82+
return;
83+
}
84+
85+
// Then add the global middlewares we need
5386
this.app.use(express.json());
5487

5588
// Add a default route

0 commit comments

Comments
 (0)