Skip to content

Commit

Permalink
TypeScript OpenAPI code generation (#24)
Browse files Browse the repository at this point in the history
* add openapi typescript definitions (partly 3rd party)

* add simple typescript generation script

* fix deprecation warning

* Update README.md

* remove commented code blocks
  • Loading branch information
lkleuver authored Apr 24, 2024
1 parent 8026f63 commit 068bddf
Show file tree
Hide file tree
Showing 6 changed files with 568 additions and 1 deletion.
21 changes: 21 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,24 @@ The application uses the following dependencies:
- `prettier`: Opinionated code formatter
- `eslint`: Linter


### Scripts

#### gen_openapi_types

Generate Typescript types from `/backend/openapi.json`.

```sh
npm run gen:openapi
```
This results in `/frontend/lib/api/gen/openapi.ts`

#### gen_icons

Generate React components from icons located in `/frontend/lib/ui/svg`

```sh
npm run gen:icons
```
This results in `/frontend/lib/icon/gen.tsx`

55 changes: 55 additions & 0 deletions frontend/lib/api/gen/openapi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Generated by ./scripts/gen_openapi_types.ts

/** PATHS **/

// /api/polling_stations/{id}/data_entries/{entry_number}
export interface POLLING_STATION_DATA_ENTRY_REQUEST_PARAMS {
id: number;
entry_number: number;
}
export type POLLING_STATION_DATA_ENTRY_REQUEST_PATH =
`/api/polling_stations/${number}/data_entries/${number};`;
export type POLLING_STATION_DATA_ENTRY_REQUEST_BODY = DataEntryRequest;

/** TYPES **/

export interface DataEntryError {
message: string;
}

/**
* Payload structure for data entry of polling station results
*/
export interface DataEntryRequest {
data: PollingStationResults;
}

/**
* PollingStationResults, following the fields in
"Model N 10-1. Proces-verbaal van een stembureau"
<https://wetten.overheid.nl/BWBR0034180/2023-11-01#Bijlage1_DivisieN10.1>
*/
export interface PollingStationResults {
voters_counts: VotersCounts;
votes_counts: VotesCounts;
}

/**
* Voters counts, part of the polling station results.
*/
export interface VotersCounts {
poll_card_count: number;
proxy_certificate_count: number;
total_admitted_voters_count: number;
voter_card_count: number;
}

/**
* Votes counts, part of the polling station results.
*/
export interface VotesCounts {
blank_votes_count: number;
invalid_votes_count: number;
total_votes_cast_count: number;
votes_candidates_counts: number;
}
1 change: 1 addition & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"e2eui": "playwright test --ui",
"ladle": "ladle serve",
"start:msw": "API_MODE=msw vite",
"gen:icons": "node scripts/gen_icons.js"
"gen:icons": "node scripts/gen_icons.js",
"gen:openapi": "vite-node ./scripts/gen_openapi_types.ts"
},
"dependencies": {
"react": "^18.2.0",
Expand Down Expand Up @@ -57,6 +58,7 @@
"prettier": "^3.2.5",
"typescript": "^5.4.5",
"vite": "^5.2.8",
"vite-node": "^1.5.0",
"vite-plugin-html": "^3.2.2",
"vitest": "^1.4.0"
},
Expand Down
148 changes: 148 additions & 0 deletions frontend/scripts/gen_openapi_types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import assert from "assert";
import fs from "fs";
import prettier from "prettier";
import { OpenAPIV3, ReferenceObject, SchemaObject, PathsObject, OperationObject } from "./openapi";

const TARGET_PATH = "./lib/api/gen";
const FILE_NAME = "openapi.ts";

async function run() {
const fileString = fs.readFileSync("../backend/openapi.json", "utf8");
if (!fileString) {
return;
}

const spec = JSON.parse(fileString) as OpenAPIV3;

if (fs.existsSync(TARGET_PATH)) {
fs.rmSync(TARGET_PATH, { recursive: true });
}
fs.mkdirSync(TARGET_PATH);

const result = ["// Generated by ./scripts/gen_openapi_types.ts\n\n"];
assert(spec["components"] !== undefined);

const schemas = spec.components.schemas;
if (!schemas) {
throw new Error("No schemas found in OpenAPI spec");
}

const paths: string[] = Object.entries(spec.paths).map(([k, v]) => addPath(k, v));
const types: string[] = Object.entries(schemas).map(([k, v]) => addDefinition(k, v));

result.push("\n\n");

result.push("/** PATHS **/\n\n");
result.push(paths.join("\n\n"));

result.push("\n\n");

result.push("/** TYPES **/\n\n");
result.push(types.join("\n\n"));

let s = result.join("\n");
s = await prettier.format(s, { parser: "typescript" });
fs.writeFileSync(`${TARGET_PATH}/${FILE_NAME}`, s);
}

run().catch((e: unknown) => {
console.error(e);
});

function addPath(path: string, v: PathsObject | undefined) {
if (!v) return "";
const result: string[] = [`// ${path}`];
let requestPath = path;
if (v.post) {
const post = v.post as OperationObject;

assert(typeof post.operationId === "string");
let id: string = post.operationId;
id = id.toUpperCase();

result.push(`export interface ${id}_REQUEST_PARAMS {`);
if (post.parameters) {
post.parameters.forEach((p) => {
if ("$ref" in p) {
result.push(`// ${p.$ref.substring(p.$ref.lastIndexOf("/") + 1)}`);
} else {
const paramType = tsType(p.schema);
requestPath = requestPath.replace(`{${p.name}}`, `\${${paramType}}`);
result.push(`${p.name}: ${paramType};`);
}
});
}
result.push("}");
result.push(`export type ${id}_REQUEST_PATH = \`${requestPath};\``);

if (post.requestBody) {
if ("$ref" in post.requestBody) {
result.push(
`export type ${id}_REQUEST_BODY = ${post.requestBody.$ref.substring(post.requestBody.$ref.lastIndexOf("/") + 1)};`
);
} else {
const media = post.requestBody.content["application/json"];
if (media?.schema) {
if ("$ref" in media.schema) {
result.push(
`export type ${id}_REQUEST_BODY = ${media.schema.$ref.substring(media.schema.$ref.lastIndexOf("/") + 1)};`
);
}
}
}
}
}

return result.join("\n");
}

function addDefinition(name: string, v: ReferenceObject | SchemaObject) {
if ("$ref" in v) {
return v.$ref.substring(v.$ref.lastIndexOf("/") + 1);
}

const result: string[] = [];
if (v.description) {
result.push("/**");
result.push(` * ${v.description}`);
result.push(" */");
}
result.push(`export interface ${name} {`);
if (v.type === "object") {
if (v.properties) {
Object.entries(v.properties).forEach(([k, v2]) => {
result.push(` ${k}${isRequired(k, v.required)}: ${tsType(v2)};`);
});
}
}
result.push("}");

return result.join("\n");
}

function tsType(s: ReferenceObject | SchemaObject | undefined): string {
//TODO: handle missing schema
if (!s) return "string";
if ("$ref" in s) {
return s.$ref.substring(s.$ref.lastIndexOf("/") + 1);
}

switch (s.type) {
case "string":
return "string";
case "integer":
return "number";
case "number":
return "number";
default:
//TODO: catch all types, any is not allowed
return "any";
}
}

function isRequired(k: string, req: string[] | undefined): string {
if (!req) {
return "";
}
return req.includes(k) ? "" : "?";
}
Loading

0 comments on commit 068bddf

Please sign in to comment.