Skip to content

Commit

Permalink
Set up type safe client server communication (#201)
Browse files Browse the repository at this point in the history
### Summary & Motivation

We want the front-end development environment to use modern React APIs,
adding the accessibility and styling ergonomics while keeping type
safety when calling the API.

Accessibility can be difficult to add if it's an afterthought. The React
Aria Components are based on solid ground and years of hard work and
effort to create the best in class components.

Forms in React have always been hard, often involving a ton of libraries
- we decided to go for the latest React APIs `useFormState` as it brings
simplicity and readability. The code should feel familiar to developers
using the latest React APIs in NextJS.

Zod is one of the best schema validation libraries out there, providing
common validations and the ability to validate and parse complex and
even nested structures. Infer allows developers to infer the TypeScript
type from a Zod Schema.

OpenAPI-fetch is an ultra-slim and performant wrapper for fetch,
enabling type-safe usage of API endpoints. Use Swashbuckle CLI to
generate a swagger.json when building the WebApi, and use
openapi-typescript CLI to generate a TypeScript definition file that can
be used for the fetch API.

* Add `WebApp.esproj` file enabling web development in Rider and Visual
Studio. `.esproj` is the new project config and will be replacing
`.njsproj` files
* Set up build of `Api` typings, creating a generated typings file to be
used by the API client
* Configure `openapi-fetch` to call the `Api`
* Configure React to enable `useFormState` APIs. This API is part of
NextJS v14 `server actions` and an official to be React API for handling
form data
* Create an example using the latest React Aria Components form
validation support. This greatly simplifies browser, client and server
validation in forms and plays nicely with `useFormState` and `zod`
* Configure CORS support in the API for development mode for now
*(Planning to set up a reverse proxy)*

Finally, update the GitHub workflow to install Bun and node modules, and
ensure that the WebApi can generate the TypeScript type definition in
the GitHub runner.

### Checklist

- [x] I have added a Label to the pull-request
- [x] I have added tests, and done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Nov 11, 2023
2 parents bddba28 + 6d25b18 commit 4e3b61b
Show file tree
Hide file tree
Showing 23 changed files with 304 additions and 39 deletions.
30 changes: 23 additions & 7 deletions .github/workflows/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,12 +36,19 @@ jobs:
echo "Generated version: $VERSION"
echo "version=$VERSION" >> $GITHUB_OUTPUT
- name: Install Bun
uses: oven-sh/setup-bun@v1

- name: Install Node modules
working-directory: application/account-management/WebApp
run: bun install

- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x

- name: Restore dependencies
- name: Restore .NET dependencies
run: dotnet restore application/PlatformPlatform.sln

- name: Build
Expand All @@ -55,6 +62,9 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3

- name: Install Bun
uses: oven-sh/setup-bun@v1

- name: Install dotCover
run: dotnet tool install --global JetBrains.dotCover.GlobalTool

Expand All @@ -78,13 +88,19 @@ jobs:
- name: Checkout code
uses: actions/checkout@v3

- name: Run code inspections
uses: muno92/[email protected]
- name: Install Bun
uses: oven-sh/setup-bun@v1

- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
solutionPath: application/PlatformPlatform.sln
minimumSeverity: warning
# Ignore cases where property getters are not called directly (e.g., on DTOs that are serialized)
ignoreIssueType: UnusedAutoPropertyAccessor.Global
dotnet-version: 7.0.x

- name: Run code inspections
working-directory: application
run: |
dotnet tool restore
dotnet jb inspectcode PlatformPlatform.sln --build --output=result.xml --severity=WARNING
account-management-api-publish:
name: Account Management API Publish
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -378,3 +378,6 @@ FodyWeavers.xsd

# macOS files
.DS_Store

# Generated files
**/*.generated.d.ts
7 changes: 7 additions & 0 deletions application/PlatformPlatform.sln
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{92101687
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "account-management\Tests\Tests.csproj", "{ABEB4337-3606-4730-8ABE-94DF98C2C348}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "account-management\WebApp\WebApp.esproj", "{8292F35B-F0F6-4DB1-90E2-4707DB8C7104}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -73,6 +75,10 @@ Global
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Release|Any CPU.Build.0 = Release|Any CPU
{8292F35B-F0F6-4DB1-90E2-4707DB8C7104}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8292F35B-F0F6-4DB1-90E2-4707DB8C7104}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8292F35B-F0F6-4DB1-90E2-4707DB8C7104}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{8292F35B-F0F6-4DB1-90E2-4707DB8C7104}.Release|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{836ABCEF-C16B-4331-B8D5-0EA75E4101FE} = {F01E4DC8-2A8B-4CB9-893A-B3B8FF2EFE22}
Expand All @@ -87,5 +93,6 @@ Global
{CB9BAD74-4362-4646-B653-3CB5FCAC74BD} = {EEA1463E-71F8-44E6-8055-787DAA5E62D2}
{92101687-8181-4543-84F9-E702DD874619} = {EEA1463E-71F8-44E6-8055-787DAA5E62D2}
{ABEB4337-3606-4730-8ABE-94DF98C2C348} = {92101687-8181-4543-84F9-E702DD874619}
{8292F35B-F0F6-4DB1-90E2-4707DB8C7104} = {EEA1463E-71F8-44E6-8055-787DAA5E62D2}
EndGlobalSection
EndGlobal
File renamed without changes.
6 changes: 6 additions & 0 deletions application/account-management/AccountManagement.sln
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{92101687
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Tests", "Tests\Tests.csproj", "{ABEB4337-3606-4730-8ABE-94DF98C2C348}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WebApp", "WebApp\WebApp.esproj", "{7E781B90-C407-4FB2-BC20-10A457E08D7F}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand All @@ -37,6 +39,10 @@ Global
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ABEB4337-3606-4730-8ABE-94DF98C2C348}.Release|Any CPU.Build.0 = Release|Any CPU
{7E781B90-C407-4FB2-BC20-10A457E08D7F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7E781B90-C407-4FB2-BC20-10A457E08D7F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7E781B90-C407-4FB2-BC20-10A457E08D7F}.Release|Any CPU.ActiveCfg = Debug|Any CPU
{7E781B90-C407-4FB2-BC20-10A457E08D7F}.Release|Any CPU.Build.0 = Debug|Any CPU
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{ABEB4337-3606-4730-8ABE-94DF98C2C348} = {92101687-8181-4543-84F9-E702DD874619}
Expand Down
8 changes: 8 additions & 0 deletions application/account-management/Api/Api.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@
</Content>
</ItemGroup>

<Target Name="CreateSwaggerJson" AfterTargets="Build" Condition="$(Configuration)=='Debug'">
<Exec Command="dotnet tool restore"/>
<Exec Command="bun install" WorkingDirectory="$(ProjectDir)/../WebApp/"/>
<Exec Command="SWAGGER_GENERATOR=true dotnet swagger tofile --output $(OutputPath)swagger.json $(OutputPath)$(AssemblyName).dll v1" WorkingDirectory="$(ProjectDir)"/>
<Exec Command="bunx openapi-typescript $(OutputPath)swagger.json -o ../WebApp/src/lib/api/api.generated.d.ts" WorkingDirectory="$(ProjectDir)"/>
<Exec Command="bunx prettier ../WebApp/src/lib/api/api.generated.d.ts --write" WorkingDirectory="$(ProjectDir)"/>
</Target>

</Project>
2 changes: 2 additions & 0 deletions application/account-management/WebApp/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Ignore artifacts:
dist
27 changes: 27 additions & 0 deletions application/account-management/WebApp/WebApp.esproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.VisualStudio.JavaScript.Sdk/0.5.88868-alpha">

<Target Name="EnsureApiBuildsFirst" BeforeTargets="Build">
<MSBuild Projects="../Api/Api.csproj" Targets="Build"/>
</Target>

<PropertyGroup>
<StartupCommand>set BROWSER=none&amp;&amp;bun dev</StartupCommand>
<JavaScriptTestRoot>src\</JavaScriptTestRoot>
<JavaScriptTestFramework>Jest</JavaScriptTestFramework>
<!-- Command to run on project build -->
<BuildCommand>bun run build</BuildCommand>
<!-- Command to create an optimized build of the project that's ready for publishing -->
<ProductionBuildCommand>bun run build</ProductionBuildCommand>
<!-- Folder where production build objects will be placed -->
<BuildOutputFolder>$(MSBuildProjectDirectory)\dist</BuildOutputFolder>

<NpmInstallCheck>$(MSBuildProjectDirectory)\bun.lockb</NpmInstallCheck>
<ShouldRunNpmInstall>false</ShouldRunNpmInstall>

</PropertyGroup>

<ItemGroup>
<Script Include="tsconfig.json"/>
</ItemGroup>

</Project>
Binary file modified application/account-management/WebApp/bun.lockb
Binary file not shown.
15 changes: 9 additions & 6 deletions application/account-management/WebApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@
"build": "rspack build"
},
"dependencies": {
"react": "^18.2.0",
"react-aria-components": "^1.0.0-beta.2",
"react-dom": "^18.2.0"
"openapi-fetch": "^0.8.1",
"react": "18.3.0-canary-c47c306a7-20231109",
"react-aria-components": "^1.0.0-rc.0",
"react-dom": "18.3.0-canary-c47c306a7-20231109",
"zod": "^3.22.4"
},
"devDependencies": {
"@rspack/cli": "latest",
"@types/react": "^18.2.33",
"@types/react-dom": "^18.2.14",
"@rspack/cli": "^0.3.11",
"@rspack/core": "^0.3.11",
"@types/react": "18.2.36",
"@types/react-dom": "18.2.13",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.31",
"postcss-loader": "^7.3.3",
Expand Down
2 changes: 1 addition & 1 deletion application/account-management/WebApp/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en-US">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta content="width=device-width, initial-scale=1.0" name="viewport" />
<title>PlatformPlatform</title>
</head>
<body>
Expand Down
18 changes: 11 additions & 7 deletions application/account-management/WebApp/rspack.config.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
const path = require("path");
const rspack = require("@rspack/core");

/**
* @type {import('@rspack/cli').Configuration}
* @type {import("@rspack/cli").Configuration}
*/
module.exports = {
context: __dirname,
entry: {
main: "./src/main.tsx",
},
builtins: {
html: [
{
template: "./public/index.html",
},
],
resolve: {
tsConfigPath: path.resolve(__dirname, "tsconfig.json"),
},
module: {
rules: [
Expand All @@ -38,4 +37,9 @@ module.exports = {
},
],
},
plugins: [
new rspack.HtmlRspackPlugin({
template: "./public/index.html",
}),
],
};
13 changes: 2 additions & 11 deletions application/account-management/WebApp/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,7 @@
import { Button } from "react-aria-components";
import { CreateTenantForm } from "@/ui/tenant/CreateTenantForm.tsx";

function App() {
return (
<div className="w-screen h-screen bg-slate-300 flex flex-col p-2">
<Button
className="bg-slate-500 p-2 rounded-md text-white text-sm border border-black hover:bg-slate-400 w-fit"
onPress={() => alert("Create tenant")}
>
Create tenant!
</Button>
</div>
);
return <CreateTenantForm />;
}

export default App;
5 changes: 5 additions & 0 deletions application/account-management/WebApp/src/lib/api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import createClient from "openapi-fetch";
import type { paths } from "./api.generated";

const baseUrl = "https://localhost:8443";
export const accountManagementApi = createClient<paths>({ baseUrl });
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { getCamelCase } from "./getCamelCase.ts";
import type { FetchResponse } from "openapi-fetch";
import { z } from "zod";

const ApiErrorListSchema = z.array(z.object({ code: z.string(), message: z.string() }));
const ApiErrorSchema = z.object({
title: z.string(),
type: z.string(),
status: z.number(),
Errors: ApiErrorListSchema,
});
type ApiErrorList = z.infer<typeof ApiErrorListSchema>;

export function getApiError(response: FetchResponse<any>) {
const { error = null } = response;
const validatedApiError = ApiErrorSchema.safeParse(error);
if (!validatedApiError.success) {
return {
title: "Unknown server error response",
status: 0,
type: "0",
Errors: [],
};
}
return validatedApiError.data;
}

export function getFieldErrors(apiErrorList: ApiErrorList) {
const fieldErrors: Record<string, string[]> = {};
apiErrorList.forEach((error) => {
const key = getCamelCase(error.code);
if (fieldErrors[key] == null) {
fieldErrors[key] = [];
}
fieldErrors[key].push(error.message);
});
console.log("api errors", { fieldErrors, apiErrorList });
return fieldErrors;
}
3 changes: 3 additions & 0 deletions application/account-management/WebApp/src/lib/getCamelCase.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function getCamelCase(text: string): string {
return text[0].toLowerCase() + text.slice(1);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Button, FieldError, Form, Input, Label, TextField } from "react-aria-components";
import { useFormState } from "react-dom";
import { createTenant, State } from "./actions";

export function CreateTenantForm() {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useFormState(createTenant, initialState);

return (
<Form
action={formAction}
validationErrors={state.errors}
className="w-screen h-screen bg-slate-900 flex flex-col p-2 justify-center items-center"
>
<div className="flex flex-col w-fit bg-slate-300 rounded-sm p-4 gap-2">
<h1 className="text-xl font-bold">Create a tenant</h1>
<TextField name={"subdomain"} autoFocus className={"flex flex-col"} isRequired>
<Label>Subdomain</Label>
<Input className="p-2 rounded-md border border-black" placeholder="subdomain" />
<FieldError />
</TextField>

<TextField name={"name"} type={"username"} className={"flex flex-col"} isRequired>
<Label>Name</Label>
<Input className="p-2 rounded-md border border-black" placeholder="name" />
<FieldError />
</TextField>

<TextField name={"email"} type={"email"} className={"flex flex-col"} isRequired>
<Label>Email</Label>
<Input className="p-2 rounded-md border border-black" placeholder="email" />
<FieldError />
</TextField>

<Button
type="submit"
className="bg-slate-500 p-2 rounded-md text-white text-sm border border-black hover:bg-slate-400 w-fit"
>
Create tenant!
</Button>
</div>
</Form>
);
}
Loading

0 comments on commit 4e3b61b

Please sign in to comment.