Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

TypeScript OpenAPI code generation #24

Merged
merged 6 commits into from
Apr 24, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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