feat(api-client): generated BFF client library (closes #66) #70

Merged
not merged 3 commits from feat/66-api-client into main 2026-07-01 10:51:02 +00:00
18 changed files with 1349 additions and 11 deletions
Showing only changes of commit 7c363099ff - Show all commits

2
.gitignore vendored
View File

@@ -50,3 +50,5 @@ out-tsc/
.nx/polygraph .nx/polygraph
vite.config.*.timestamp* vite.config.*.timestamp*
vitest.config.*.timestamp* vitest.config.*.timestamp*
.angular

View File

@@ -0,0 +1,7 @@
# api-client
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test api-client` to execute the unit tests.

View File

@@ -0,0 +1,34 @@
import nx from '@nx/eslint-plugin';
import baseConfig from '../../eslint.config.mjs';
export default [
...nx.configs['flat/angular'],
...nx.configs['flat/angular-template'],
...baseConfig,
{
files: ['**/*.ts'],
rules: {
'@angular-eslint/directive-selector': [
'error',
{
type: 'attribute',
prefix: 'lib',
style: 'camelCase',
},
],
'@angular-eslint/component-selector': [
'error',
{
type: 'element',
prefix: 'lib',
style: 'kebab-case',
},
],
},
},
{
files: ['**/*.html'],
// Override or add rules here
rules: {},
},
];

View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'orval';
// Generates the BFF client (Angular HttpClient service + models) from the committed
// OpenAPI contract. Never hand-edit the generated output — re-run `nx run api-client:generate`
// after the BFF spec changes (CLAUDE.md §10; docs/frontend-decisions.md).
export default defineConfig({
bff: {
input: '../../services/bff/openapi.json',
output: {
target: './src/lib/generated/bff-api.ts',
client: 'angular',
mode: 'single',
clean: true,
prettier: true,
},
},
});

View File

@@ -0,0 +1,13 @@
{
"name": "api-client",
"$schema": "../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/api-client/src",
"prefix": "lib",
"projectType": "library",
"tags": [],
"targets": {
"lint": {
"executor": "@nx/eslint:lint"
}
}
}

View File

@@ -0,0 +1 @@
export {};

View File

@@ -0,0 +1,50 @@
import { provideHttpClient } from '@angular/common/http';
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import {
BffApiV1Service,
type OpenbaarEntry,
type SubmitAccepted,
} from 'api-client';
describe('BffApiV1Service (generated from services/bff/openapi.json)', () => {
let service: BffApiV1Service;
let http: HttpTestingController;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [provideHttpClient(), provideHttpClientTesting()],
});
service = TestBed.inject(BffApiV1Service);
http = TestBed.inject(HttpTestingController);
});
afterEach(() => http.verify());
it('submits a registration via POST /self-service/registrations', () => {
let result: SubmitAccepted | undefined;
service.postSelfServiceRegistrations().subscribe((r) => (result = r));
const req = http.expectOne('/self-service/registrations');
expect(req.request.method).toBe('POST');
req.flush({ registrationId: 'reg-1', status: 'Ingediend' });
expect(result?.registrationId).toBe('reg-1');
expect(result?.status).toBe('Ingediend');
});
it('reads the openbaar register via GET /openbaar/register with the query', () => {
let rows: OpenbaarEntry[] | undefined;
service.getOpenbaarRegister({ q: 'abc' }).subscribe((r) => (rows = r));
const req = http.expectOne((r) => r.url === '/openbaar/register');
expect(req.request.method).toBe('GET');
expect(req.request.params.get('q')).toBe('abc');
req.flush([{ id: 'abc-111', status: 'INGEDIEND' }]);
expect(rows?.[0].id).toBe('abc-111');
});
});

View File

@@ -0,0 +1,216 @@
/**
* Generated by orval v8.19.0 🍺
* Do not edit manually.
* Bff.Api | v1
* OpenAPI spec version: 1.0.0
*/
import {
HttpClient,
HttpHeaders,
HttpResponse as AngularHttpResponse
} from '@angular/common/http';
import type {
HttpContext,
HttpEvent,
HttpParams
} from '@angular/common/http';
import {
Injectable,
inject
} from '@angular/core';
import {
Observable
} from 'rxjs';
export interface OpenbaarEntry {
id: string;
status: string;
}
export interface SubmitAccepted {
registrationId: string;
status: string;
}
export type GetOpenbaarRegisterParams = {
q?: string;
};
interface HttpClientOptions {
readonly headers?: HttpHeaders | Record<string, string | string[]>;
readonly context?: HttpContext;
readonly params?:
| HttpParams
| Record<string, string | number | boolean | Array<string | number | boolean>>;
readonly reportProgress?: boolean;
readonly withCredentials?: boolean;
readonly credentials?: RequestCredentials;
readonly keepalive?: boolean;
readonly priority?: RequestPriority;
readonly cache?: RequestCache;
readonly mode?: RequestMode;
readonly redirect?: RequestRedirect;
readonly referrer?: string;
readonly integrity?: string;
readonly referrerPolicy?: ReferrerPolicy;
readonly transferCache?: {includeHeaders?: string[]} | boolean;
readonly timeout?: number;
}
type HttpClientBodyOptions = HttpClientOptions & {
readonly observe?: 'body';
};
type HttpClientEventOptions = HttpClientOptions & {
readonly observe: 'events';
};
type HttpClientResponseOptions = HttpClientOptions & {
readonly observe: 'response';
};
type HttpClientObserveOptions = HttpClientOptions & {
readonly observe?: 'body' | 'events' | 'response';
};
type AngularHttpParamValue = string | number | boolean | Array<string | number | boolean>;
type AngularHttpParamValueWithNullable = AngularHttpParamValue | null;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys?: ReadonlySet<string>,
preserveRequiredNullables?: false,
passthroughKeys?: undefined,
): Record<string, AngularHttpParamValue>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> | undefined,
preserveRequiredNullables: true,
passthroughKeys?: undefined,
): Record<string, AngularHttpParamValueWithNullable>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> | undefined,
preserveRequiredNullables: boolean | undefined,
passthroughKeys: ReadonlySet<string>,
): Record<string, unknown>;
function filterParams(
params: Record<string, unknown>,
requiredNullableKeys: ReadonlySet<string> = new Set(),
preserveRequiredNullables = false,
passthroughKeys: ReadonlySet<string> = new Set(),
): Record<string, unknown> {
const filteredParams: Record<string, unknown> = {};
for (const [key, value] of Object.entries(params)) {
if (passthroughKeys.has(key)) {
if (value !== undefined) {
filteredParams[key] = value;
}
continue;
}
if (Array.isArray(value)) {
const filtered = value.filter(
(item) =>
item != null &&
(typeof item === 'string' ||
typeof item === 'number' ||
typeof item === 'boolean'),
) as Array<string | number | boolean>;
if (filtered.length) {
filteredParams[key] = filtered;
}
} else if (
preserveRequiredNullables &&
value === null &&
requiredNullableKeys.has(key)
) {
filteredParams[key] = null;
} else if (
value != null &&
(typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean')
) {
filteredParams[key] = value;
}
}
return filteredParams;
}
@Injectable({ providedIn: 'root' })
export class BffApiV1Service {
private readonly http = inject(HttpClient);
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientBodyOptions): Observable<TData>;
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
postSelfServiceRegistrations<TData = SubmitAccepted>( options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
postSelfServiceRegistrations<TData = SubmitAccepted>(
options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
if (options?.observe === 'events') {
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
}
);
}
if (options?.observe === 'response') {
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
}
);
}
return this.http.post<TData>(
`/self-service/registrations`,
undefined,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
}
);
}
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientBodyOptions): Observable<TData>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientEventOptions): Observable<HttpEvent<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(params?: GetOpenbaarRegisterParams, options?: HttpClientResponseOptions): Observable<AngularHttpResponse<TData>>;
getOpenbaarRegister<TData = OpenbaarEntry[]>(
params?: GetOpenbaarRegisterParams, options?: HttpClientObserveOptions): Observable<TData | HttpEvent<TData> | AngularHttpResponse<TData>> {
const filteredParams = filterParams({...params, ...options?.params}, new Set<string>([]));
if (options?.observe === 'events') {
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'events',
params: filteredParams,}
);
}
if (options?.observe === 'response') {
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'response',
params: filteredParams,}
);
}
return this.http.get<TData>(
`/openbaar/register`,{
...(options as Omit<NonNullable<typeof options>, 'observe'>),
observe: 'body',
params: filteredParams,}
);
}
};

View File

@@ -0,0 +1,5 @@
import '@angular/compiler';
import '@analogjs/vitest-angular/setup-snapshots';
import { setupTestBed } from '@analogjs/vitest-angular/setup-testbed';
setupTestBed({ zoneless: false });

View File

@@ -0,0 +1,31 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"isolatedModules": true,
"target": "es2022",
"moduleResolution": "bundler",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"emitDecoratorMetadata": false,
"module": "preserve"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

View File

@@ -0,0 +1,26 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"declaration": true,
"declarationMap": true,
"inlineSources": true,
"types": []
},
"include": ["src/**/*.ts"],
"exclude": [
"src/**/*.spec.ts",
"src/**/*.test.ts",
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/test-setup.ts"
]
}

View File

@@ -0,0 +1,29 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../dist/out-tsc",
"types": [
"vitest/globals",
"vitest/importMeta",
"vite/client",
"node",
"vitest"
]
},
"include": [
"vite.config.ts",
"vite.config.mts",
"vitest.config.ts",
"vitest.config.mts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.test.tsx",
"src/**/*.spec.tsx",
"src/**/*.test.js",
"src/**/*.spec.js",
"src/**/*.test.jsx",
"src/**/*.spec.jsx",
"src/**/*.d.ts"
],
"files": ["src/test-setup.ts"]
}

View File

@@ -0,0 +1,28 @@
/// <reference types='vitest' />
import { defineConfig } from 'vite';
import angular from '@analogjs/vite-plugin-angular';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { nxCopyAssetsPlugin } from '@nx/vite/plugins/nx-copy-assets.plugin';
export default defineConfig(() => ({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/api-client',
plugins: [angular(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers.
// worker: {
// plugins: () => [ nxViteTsPaths() ],
// },
test: {
name: 'api-client',
watch: false,
globals: true,
environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
setupFiles: ['src/test-setup.ts'],
reporters: ['default'],
coverage: {
reportsDirectory: '../../coverage/libs/api-client',
provider: 'v8' as const,
},
},
}));

View File

@@ -97,7 +97,7 @@
{ {
"plugin": "@nx/vitest", "plugin": "@nx/vitest",
"options": { "options": {
"testTargetName": "vite:test" "testTargetName": "test"
} }
} }
], ],

View File

@@ -52,6 +52,7 @@
"jsdom": "~22.1.0", "jsdom": "~22.1.0",
"jsonc-eslint-parser": "^2.1.0", "jsonc-eslint-parser": "^2.1.0",
"nx": "23.0.1", "nx": "23.0.1",
"orval": "^8.19.0",
"prettier": "^3.8.1", "prettier": "^3.8.1",
"ts-node": "10.9.1", "ts-node": "10.9.1",
"tslib": "^2.3.0", "tslib": "^2.3.0",

878
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,17 +10,13 @@
"importHelpers": true, "importHelpers": true,
"target": "es2015", "target": "es2015",
"module": "esnext", "module": "esnext",
"lib": [ "lib": ["es2020", "dom"],
"es2020",
"dom"
],
"skipLibCheck": true, "skipLibCheck": true,
"skipDefaultLibCheck": true, "skipDefaultLibCheck": true,
"baseUrl": ".", "baseUrl": ".",
"paths": {} "paths": {
"api-client": ["./libs/api-client/src/index.ts"]
}
}, },
"exclude": [ "exclude": ["node_modules", "tmp"]
"node_modules", }
"tmp"
]
}

4
vitest.workspace.ts Normal file
View File

@@ -0,0 +1,4 @@
export default [
'**/vite.config.{mjs,js,ts,mts}',
'**/vitest.config.{mjs,js,ts,mts}',
];