; Date: Sun Mar 30 2025
Tags: Node.JS »»»» TypeScript »»»» OpenAPI »»»»
With OpenAPI we describe web API methods and data types in a language-neutral format. I guarantee that Nirvana is reached with auto-generated type definitions and data validation, in API client and server frameworks.

An API specification written with OpenAPI is data, either in JSON or YAML format. It's a description of REST API endpoints, request parameters, success and failure results, data types, and security mechanisms. Because the specification is easily-parsable data, software tools can ingest the API description and then auto-generate all kinds of software assets.
This enables creating an interesting virtuous cycle where specification changes are easily converted to type declarations, data validation functions, and API client libraries. Changing the specification is then an easily handled task. One regenerates type definitions, and client- or server-side code shims. Then, your IDE of choice will guide you in updating your application by showing you mismatched types or API functions.
In this article we will explore several tools for the TypeScript and Node.js platform for auto-generating REST client libraries, with auto-generated data type declarations, and auto-generated data validation.
In my case, I am working with a specific protocol (OpenADR). I've implemented a server for the OpenADR protocol, and am interested in automated data validation, and automatically generated type declarations. I want a client library for this protocol, where the API methods match the operation names in the specification, where TypeScript shows precise type declarations, and where data handled by the client library is automatically validated.
Architectural Overview
The desired result is for REST client and server code to automatically validate data, and make REST API calls, somewhat like this:

The coder is to use an IDE that does intelligent things with well-defined data types that are auto-generated from an OpenAPI specification. Those types help the IDE to inform the coder how to create objects and functions.
Client-side data validation is to ensure that the client does not send invalid data.
Server-side data validation is to ensure that the request parameters it receives are correct.
Server-side business logic uses data validation to ensure it recieves correct parameters.
Server-side data validation ensures that the response is correctly formatted.
Errors are returned to the client code, and the server-side stops its work immediately upon an error.
For this article we're focused on the first two columns:
- Auto-generated client-side data types and data validation
- These are also available to the client application
- Auto-generated client-side functions to make REST requests to a server
- These expose responses and errors sent by the server to the client application
Auto-generated TypeScript type declarations from an OpenAPI specification
An earlier article contains a long list of tools for generating type declarations in the TypeScript/Node.js environment.
From that experience, I found
@openapi-codegen/cli produces the best quality TypeScript type declarations. The types are close to what I would write by hand, plus it includes all OpenAPI attributes as JSDOC tags.
The strong support of JSDOC tags is important because of the many tools which read those tags while serving other purposes. An example is to auto-generate validation schema's (such as for Zod or Joi) from TypeScript declarations.
An example the from OpenADR protocol, and was discussed in the schema/types article, are the DateTime and Duration data types. DateTime refers to date/time strings like 2024-11-04T01:02:03Z
, whose format is defined by ISO8601. Durations are strings defined in ISO8601 that is a structured definition of time intervals. It's not enough to validate either DateTime or Duration objects by testing that it is a string. Instead, validating either means testing whether the string conforms to the corresponding text format. In other words, such strings are structured data, a.k.a. objects, whose representation is a string. For that purpose, the OpenADR spec uses the date-time
format for DateTime, and a regular expression describing the Duration format, to validate correctness.
Namely:
dateTime:
type: string
format: date-time
description: datetime in ISO 8601 format
example: 2023-06-15T09:30:00Z
duration:
type: string
pattern: "^(-?)P(?=\\d|T\\d)(?:(\\d+)Y)?(?:(\\d+)M)?(?:(\\d+)([DW]))?(?:T(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+(?:\\.\\d+)?)S)?)?$"
description: duration in ISO 8601 format
example: PT1H
default: PT0S
The specification is explicit that both are strings of a certain format.
For the task of converting this to a TypeScript type, it's more than declaring:
export type DateTime = string;
export type Duration = string;
Both those definitions eradicate any ability to know whether any string matches the actual type requirements. The information is there in the schema definition, but was not carried over to the TypeScript type.
What @openapi-codegen does is to present those attributes as JSDOC tags. If those attributes are not reflected as JSDOC tags, then the type is not correctly declared. In both cases the type is not a simple string, but is a string containing an object whose representation is a string.
That then connects to a range of tools that know about JSDOC tags, including the VS Code IDE and other similar tools.
/**
* datetime in ISO 8601 format
*
* @format date-time
* @example "2023-06-15T09:30:00.000Z"
*/
export type DateTime = string;
/**
* duration in ISO 8601 format
*
* @pattern ^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$
* @example PT1H
* @default PT0S
*/
export type Duration = string;
These are the type definitions generated by @openapi-codegen. For DateTime there is a JSDOC tag that covers ISO8601 date/time strings. But, for duration strings, there is no such JSDOC tag, and instead we use this long regular expression.
To use @openapi-codegen/cli
, we must supply a configuration file. This one is written in TypeScript:
import {
generateSchemaTypes,
generateFetchers
} from "@openapi-codegen/typescript";
import {
defineConfig
} from "@openapi-codegen/cli";
export default defineConfig({
OADR: {
from: {
relativePath: "../oadr3.0.1.yaml", // PATH TO THE SPECIFICATION
source: "file",
},
outputDir: "./codegen-build",
to: async (context) => {
const { schemasFiles } = await generateSchemaTypes(context, {
filenamePrefix: "OADR",
});
await generateFetchers(context, {
/* config */
schemasFiles,
});
},
},
});
This says to generate both schema types and fetchers. The first generates the type definitions. The second generates a client library which includes type definitions for the parameters of certain REST endpoints.
I found the generated fetcher functions to be too convoluted to understand what was going on. I did not see a way to insert OAuth2 authentication headers into the requests, for instance. Therefore, I have chosen to eliminate the client library portion of the Fetchers output. But, the Fetchers output includes useful declarations for request parameters, and responses, for the client methods. With some manual post-processing of the Fetchers, we're left with the useful part.
Automatically generating data validation schema's from an OpenAPI specification
With auto-generated data type declarations handled, the next step in developing a type-safe REST client is auto-generated data validation functions.
The tool chosen for this purpose is the Joi schema's produced by
@savotije/openapi-to-joi. The Zod schema's generated by openapi-to-zod are also very good.
The difference is that generated Joi schema's covers the REST API request parameters. The generated Zod code only covers the schema objects in the #/components/schemas
section of an OpenAPI spec. To validate API function parameters Many REST endpoints have multiple request parameters, and it's useful to treat that as an object type with its own data validation.
Code is generated by running this command.
$ npx openapi-to-joi ${spec} -o ./openapi-to-joi/oadr3.js
The validation code for DateTime and Duration are:
export const schemas = {
// ...
components: {
// ...
dateTime: Joi.date().description("datetime in ISO 8601 format"),
duration: Joi.string()
.allow("")
.default("PT0S")
.description("duration in ISO 8601 format")
.pattern(
/^(-?)P(?=\d|T\d)(?:(\d+)Y)?(?:(\d+)M)?(?:(\d+)([DW]))?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$/,
{}
),
// ...
}
// ...
}
Both of these are reasonably well structured, and are identical to what a coder might write by hand. The validation code snippets are stored within an object structure, and are therefore addressable as schemas.component.SCHEMA_NAME
.
To use the validation schema's, a series of functions are written:
export const joiDateTime = schemas.components.dateTime;
export function joiValidateDateTime(prog: any): Joi.ValidationResult<OADR3.DateTime> {
return joiDateTime.validate(prog, { allowUnknown: true });
}
export const joiDuration = schemas.components.duration;
export function joiValidateDuration(prog: any): Joi.ValidationResult<OADR3.Duration> {
return joiDuration.validate(prog, { allowUnknown: true });
}
Also of interest is data validation for REST request parameters. They are stored as schemas.parameters.REQUEST_OPERATION_NAME.query
, and we can write a series of functions like this:
export const joiSearchAllEvents = schemas.parameters.searchAllEvents.query;
export function joiValidateSearchAllEvents(prog: any): Joi.ValidationResult<any> {
return joiSearchAllEvents.validate(prog, { allowUnknown: true });
}
The OADR3.DateTime
and OADR3.Duration
types were generated by OpenAPI Codegen as discussed in the previous section.
These functions are manually coded, but rely on the auto-generated Joi schemas. The rationale for creating these functions is to simplify schema usage.
Generating REST client libraries for TypeScript/Node.js from an OpenAPI specification
What we've discussed so far lays the ground-work for the client library. The data type declarations, and data validation functions, will be useful to any application, and will make the client library more powerful.
As with the task of auto-generating data types, I tried several tools. Notes were left in a GitHub repository
-
@openapi-codegen - As noted earlier, this library can produce a Fetchers library that appears meant to be a REST client library. But it is convoluted enough, and not documented well enough, that I gave it a pass.
-
OpenAPIGenerator - This is a Java-based tool that appears to be a fork of the Swagger Tools. There is a Docker container making it simpler to run these tools. The code TypeScript generated for a client library is not usable. It is possible to customize the output by writing Mustache templates. But, this is not well documented, and while I was able to make some progress towards a solution, there were several things I could not solve.
-
OpenAPIStack and
openapi-client-axios
- OpenAPIStack is a complete toolchain for implementing clients and servers, on Node.js, from OpenAPI specifications.
What was chosen is
OpenAPI Fetch. This package is part of the OpenAPI TS suite of tools. It creates a client object that's dynamically built from the
paths
array in the OpenAPI specification.
The implementation shown below wraps around that client. The wrapper handles OAuth2 client credential tokens, and performs data validation on both the data sent to the server, and on the response from the server.
To get started, install the package and its dependencies like so:
$ npm install openapi-fetch --save
$ npm install --save-dev openapi-typescript typescript
Using the TypeScript compiler obviously involves other setup like the tsconfig.json
file.
Initializing the OpenAPI Fetch client
Next, one uses openapi-typescript
to generate types declarations for your specification. This is somewhat redundant since we said earlier that OpenAPI Codegen produces the best type declarations, which it does. However, OpenAPI Fetch uses a specific portion of the output from openapi-typescript
.
$ npx openapi-typescript ./path/to/api/v1.yaml -o ./src/lib/api/v1.d.ts
In short, you give the path/to/specification.yaml
on the command-line, and the -o
option says where to place the declarations file.
In your code, you then initialize an OpenAPI Fetch client like so:
import createClient from "openapi-fetch";
import type { paths } from "./src/lib/api/v1.d.ts";
// ...
const client = createClient<paths>({ baseUrl: "https://myapi.dev/v1/" });
// ...
const {
data, // only present if 2XX response
error, // only present if 4XX or 5XX response
} = await client.GET("/blogposts/{post_id}", {
params: {
path: { post_id: "123" },
},
});
The first imports the auto-generated paths
object. This object contains the data related to every operation ID, the URL paths, parameters, responses, and so forth.
The createClient
function creates a client object from those paths.
And then, with the client object you can do client.METHOD('/url/path', { options, params })
to invoke functions on the REST server.
Client library overview
The OpenAPI Fetch client object looks good. Further, I didn't test this, but it should be possible to create multiple client objects to access multiple servers. However, some additional capability was required:
- Managing client ID/secret tokens for OAuth2 Client Credential Flow, and storing the Client Credential Response in order to use its access token.
- Data validation of request parameters and response data.
- An operation ID centered client, rather than supplying the
/url/path
.
For the client library, I implemented a TypeScript class that containing methods corresponding to the operations defined in the OpenADR specification, and any other methods deemed necessary.
For that purpose, a class named OADR3Client
was created which is discussed in the following.
export class OADR3Client {
#oadr3URL: URL;
#name: string;
#client_id: string;
#client_secret: string;
#scope?: string;
#authToken?: OADR3.ClientCredentialResponse;
constructor(oadr3URL: URL, name: string,
client_id: string, client_secret: string, scope?: string) {
// ...
this.#oadr3URL = oadr3URL;
this.#name = name;
this.#client_id = client_id;
this.#client_secret = client_secret;
this.#scope = scope;
this.#authToken = undefined;
// ...
}
// ...
}
There are additional private fields and checks not shown here.
This, like many protocols, uses OAuth2 for authentication, specifically the Client Credential flow. The client_id
and client_secret
fields contain tokens that are exchanged for an auth token, which is stored in authToken
. The type OADR3.ClientCredentialResponse
is derived directly from the OAuth2 specification, and is defined in the OpenADR specification.
Implementing authentication, such as OAuth2 Client Credential Flow
The OAuth2 Client Credential Flow is used because OpenADR is a machine-machine protocol, and CCF is meant for cases where there is no human interaction.
In Client Credential Flow, the client software makes a REST call to an authorization server providing the client_id
and client_secret
tokens (the ClientCredentialRequest
class). The server responds with an auth_token
and other data (the ClientCredentialResponse
class). The auth_token
is then used in the header of subsequent requests.
The desired result is that a bearer token header, Authorization: Bearer OAUTH2-ACCESS-TOKEN
, must exist in requests to the server. The server must validate this token before proceeding with request handling.
The implementation shown here is for the server to implement an endpoint, /auth/token
, that maps to the fetchToken
handler function. The OpenADR specification describes this as one option for generating an authorization token. Starting with version 3.1, there is an /auth/server
endpoint which returns a AuthServerInfo
object describing the authorization server endpoints.
The fetchToken
method is what invokes the request to the server that will convert the client_id
and client_secret
tokens into an auth_token
. This function manages remembering the token in this.#authToken
, which is a ClientCredentialResponse
object. The key member of that object is the auth_token
field.
async fetchToken() {
// If a token is already available, skip all the rest
// and return the token.
if (this.#authToken) {
return this.#authToken;
}
// No token is currently known, so fetch one from the server
let request: OADR3.ClientCredentialRequest;
{
let { error, value } = OADR3.joiValidateClientCredentialRequest({
grant_type: "client_credentials",
client_id: this.#client_id,
client_secret: this.#client_secret,
scope: this.#scope
});
if (error) {
throw new Error(`fetchToken FAIL ${util.inspect(error.details)}`);
}
request = value;
}
// At this point, use a library like "got" to POST
// the ClientCredentialsRequest to the OpenADR
// endpoint - /auth/token - which is to validate
// the request, generate a token, and respond
// with a ClientCredentialResponse object.
// After checking for a good response, parse the
// JSON to produce a ClientCredentialResponse object
// and save that into this.#authToken
const reqHeaders = new Headers();
reqHeaders.set('Content-Type',
'application/x-www-form-urlencoded');
const { data, error } = await this.#client.POST('/auth/token', {
headers: reqHeaders,
body: request,
// Encoding an object in the
// x-www-form-urlencoded format
// is done with URLSearchParams.
bodySerializer(body: any) {
const ret = new URLSearchParams(
Object.entries(body)
); // .toString();
// console.log(`serialize /auth/token body ${ret.toString()}`);
return ret;
}
});
if (error) {
throw new Error(`#fetchToken ERROR ${error.type} ${error.status} ${error.title} ${error.detail}`);
}
let ccresponse: OADR3.ClientCredentialResponse;
{
const { error, value } = OADR3.joiClientCredentialResponse.validate(data);
if (error) {
throw new Error(`fetchToken FAIL response body not Client Credential Response - ${util.inspect(error.details)}`);
}
ccresponse = value;
}
// console.log(`fetchToken parsed ${util.inspect(ccresponse)}`);
this.#rememberOAuth2Token(ccresponse);
return ccresponse;
}
The ClientCredentialResponse
type is also derived from the OAuth2 specification, and is auto-generated from the OpenADR specification as OADR3.ClientCredentialResponse
.
The function OADR3.joiValidateClientCredentialRequest
is a way to convert the anonymous object into the ClientCredentialRequest
type. The signature is:
export function joiValidateClientCredentialRequest(prog: any): Joi.ValidationResult<OADR3.ClientCredentialRequest>;
In other words, this function accepts any object, validates that it is the required type, then returns a ValidationResult
whose value
member is of type OADR3.ClientCredentialRequest
,
The content type for the /auth/token
request is required to be application/x-www-form-urlencoded
. The code here is what's required to use that content type using this HTTP client library.
The response is supposed to be a OADR3.ClientCredentialResponse
. Like with joiValidateClientCredentialRequest
, the OADR3.joiClientCredentialResponse.validate
validates that the response is correct, and returns value
with the correct type.
Finally, the internal function, #rememberOAuth2Token
, stores the token along with its expiration time.
#authTokenTimeout?: any;
#rememberOAuth2Token(ccresponse: OADR3.ClientCredentialResponse) {
if (typeof this.#authTokenTimeout !== 'undefined') {
clearTimeout(this.#authTokenTimeout);
this.#authTokenTimeout = undefined;
}
this.#authToken = ccresponse;
if (typeof ccresponse.expires_in === 'number') {
this.#authTokenTimeout = setTimeout(() => {
this.#authToken = undefined;
}, ccresponse.expires_in - 15);
}
}
This function also sets up a timer which will cause the token to be erased before it expires. Forgetting the token will cause this client library to automatically acquire a new token on the next request.
Hand-generated client functions, using auto-generated support functions
We have a class OADR3Client
that's the shell within which we're building a an OpenADR 3 client library. We've discussed implementing authentication using OAuth2. Let's move on to developing functions matching the REST API.
The approach that was developed after much experimentation was to write, by hand, a client-side function for each operation ID in the API. This function is a shell using several utility functions, primarily the data validation functions discussed earlier.
The API deals with five object types: Program, Event, Subscription, _VEN, and Resource. It's not important for our discussion what those objects represent.
For each object type, there are five operations: searchAllOBJECT, createOBJECT, searchOBJECTById, updateOBJECT, and deleteOBJECT. These are the CRUD operations, plus one for retrieving a single object by it's ID. For example, the protocol includes searchAllEvents, createEvent, searchEventById, _updateEvent, and deleteEvent functions.
It was found that creating five highly parameterized methods matching those five operations greatly simplified the client implementation.
For example, the createEvent
function is:
async createEvent(
event: OADR3.Event
) : Promise<OADR3.Event | undefined> {
return this.#createObject<OADR3.Event>(
event,
'/events',
undefined,
OADR3.joiValidateEvent
);
}
It's given an OADR3.Event
object, and returns a Promise
for the same.
Behind the scenes it calls an internal function, createObject
. This is one of the five highly parameterized functions. It's given the data type, the object, the endpoint URL, and the validation function to use for the createEvent
operation.
Matching the five operations are five internal operation handlers: #searchAllObjects
, #createObject
, #searchObjectsByID
, #updateObject
, #deleteObject
. Each of these are just as highly parameterized, and are supplied with appropriate data types, parameters, endpoint URLs, and validation functions.
Each such method follows this outline
- Validate the input parameters
- Compute the endpoint URL, including any URL parameters
- Generate the authorization header
- Send the REST call to the server
- Get the result, verifying the status code
- JSON parse the response body
- Validate the response object
For example, #createObject
is implemented as so:
async #createObject<Tobj>(
object: Tobj,
endpoint: string,
pathParams: any | undefined,
validateObj: (data: any) => any
): Promise<Tobj | undefined> {
const _obj = this.#validateObject<Tobj>(
validateObj, object
);
const { data, error } = await this.#client
.POST(endpoint,
pathParams
? {
params: { path: pathParams },
body: _obj
}
: { body: _obj }
);
if (error) {
throw new Error(`#createObject ERROR ${error.type} ${error.status} ${error.title} ${error.detail}`);
}
return this.#validateObject<Tobj>(
validateObj, data);
}
It starts by validating the object is of the correct type. It then uses the #client
object to POST to the /event
endpoint (passed as a parameter), with the required parameters. If this returns errors, a suitable error is thrown. Otherwise, the return object is validated and returned.
The #validateObject
method, and the #validateParams
not shown here, validates a single object, of a given object type, using a given validation function. If the object fails validation, it throws an Error. Otherwise, the validated object is returned with the provided data type.
In some cases the endpoint path might be parameterized. For example your API might deal with restaurant locations in a fast food chain. The endpoint /locations
deals with individual locations, /locations/{locationID}
deals with a given location, /locations/{locationID}/parking
deals with the parking lot, and /locations/{locationID}/parking/{lotID}
deals with an individual parking lot.
When that's the case, the endpoint
parameter is /locations/{locationID}
, and pathParams
is an object describing values for the parameterized URL, such as { locationID: location.id }
.
Our goal was to have auto-generated client-side functions. But, what we ended up with is a hybrid of manually-generated and auto-generated code.
Finishing the OAuth2 implementation and avoiding an infinite loop
The fetchToken
function shown earlier is only the start of handling OAuth2 token needs. That function must be invoked when an auth_token
is required.
With the OpenAPI Fetch framework, the OADR3Client
class must have a private field to hold the client object:
export class OADR3Client {
// ...
#client?: any;
// ...
constructor( /* params */) {
// ...
this.#client = createClient<paths>({
baseUrl: oadr3URL.href
});
this.#client.use(this.#authMiddleware);
// ...
}
// ...
}
The variable, oadr3URL.href
, is the base URL for the server.
The #authMiddleware
is where we implement OAuth2 token provisioning, and adding the Authorization
header to outgoing requests. The OpenAPI Fetch supports onRequest()
, onResponse()
and onError()
middleware functions, which are called as each condition occurs.
In this case, the Authorization
header is generated by calling onRequest()
. That function calls fetchToken()
, to either return the current token or retrieve one from the server. Once it has the token, it can add Authorization: Bearer TOKEN
to the headers.
A naive implementation of this causes an infinite loop, that then leads to a RangeError: Maximum call stack size exceeded error. One way to trigger that error is to pass enough data in a JavaScript function call to exceed the call stack size. In this case, the error came about due to recursive calls to fetchToken
.
The cause for recursive fetchToken
calls is simple. When it invokes this.#client.POST('/auth/token' ...)
, the onRequest
middleware function is triggered. That onRequest
will again notice the need to call fetchToken
, which will make the same POST to /auth/token
, which will invoke onRequest
, which... continues looping back on itself until the call stack is exceeded.
Keeping that in mind, read:
#authMiddleware: Middleware = ((self) => {
return <Middleware>{
async onRequest({ request, schemaPath }) {
// console.log(`onRequest ${util.inspect(request)}`);
// Do nothing for auth requests. Avoid call stack size exceeded.
if (schemaPath === '/auth/token') {
// console.log(`onRequest SKIP ${request.method} ${request.url}`);
return undefined;
}
// console.log(`onRequest not skipped ${request.method} ${request.url}`);
// fetch token, if it doesn’t exist
if (!self.#authToken) {
const authRes = await self.fetchToken();
if (authRes.access_token) {
self.#authToken = authRes;
} else {
// handle auth error
}
}
if (!self.#authToken?.access_token
|| !(typeof self.#authToken.access_token === 'string')
) {
throw new Error(`authMiddleware did not generate access_token`);
}
// add Authorization header to every request
request.headers.set(
"Authorization",
`Bearer ${self.#authToken?.access_token}`
);
return request;
},
}})(this);
First, we need to discuss how the field #authMiddleware
is assigned:
#authMiddleware: Middleware = ((self) => {
return <Middleware>{
async onRequest({ request, schemaPath }) {
// ...
}
};
}})(this);
This is a call to an anonymous function, where the function has a parameter named self
which is supplied from this
referring to the OADR3Client
instance. As an OpenAPI Fetch middleware, this function is invoked on every outgoing request to the REST service.
The first order of business is avoiding the call stack size exceeded error just discussed. The schemaPath === '/auth/token'
test takes care of that by returning immediately. The OpenAPI Fetch documentation says to do the following, which is what we've done:
async onRequest({ request, schemaPath }) {
if (schemaPath === '/auth/token') {
return undefined;
}
}
Next, if there is no current auth token (tested with !self.#authToken
), the fetchToken
method is invoked to retrieve a token. When that makes a call to the /auth/token
REST endpoint, the onRequest
function will be executed again, but this time since schemaPath
is /auth/token
there will be no infinite loop. If all is well, self.#authToken
will hold the ClientCredentialResponse
instance, which in turn has the access_token
field.
Finally, the request.headers.set
will have an Authorization
header added, as discussed earlier.
Diagram for adding the authorization header
This diagram may be helpful for understanding the above code.

Why wasn't OpenAPI Codegen used for client functions?
Implementing the client functions could possibly have been simpler by using the Fetchers functions generated by OpenAPI Codegen. My statement above that it was convoluted enough that I gave that a pass is actually lame on my part. I have been awarded four patents for earlier software development projects, surely I could figure out how to use the Fetchers?
Thinking along those lines, I spendt a few hours studying the Fetchers. And, I came to the same conclusion that the Fetchers did not match my needs.
The first issue is that the Fetchers functions are just that, a collection of functions. That's great so long as your application only needs one client instance, meaning it only needs to communicate with one server. But, what if your application needs to communicate using the same protocol with multiple servers?
The approach described earlier, the OADR3Client
class, allows an application to create multiple instances of that object each with its own settings. It's harder (not impossible) to do this with a collection of functions.
I then tried rewriting the <prefix>Fetch.ts
file and the <prefix>Fetch
function.
It was fairly easy to transplant the OAuth2 Client Credential Flow methodology described above into their <prefix>Fetch
function. I didn't test the result, but the code looked correct, and looked like it would be easy to debug.
Where I stopped is the next step, which was to work out how to perform data validation. As discussed earlier it's necessary to validate the data which will be sent to the server via the REST call, and to validate the data returned from the REST server. Hence, validation happens at at the beginning of <prefix>Fetch
and when the function returns the response data.
The problem is that it would be very difficult, inside <prefix>Fetch
, to do that data validation.
Why not use OpenAPI Fetch as one-stop-shopping?
The OpenAPI Fetch package is part of the OpenAPI TS toolchain. Between it and OpenAPI TypeScript, an application has both good quality type declarations autogenerated from an OpenAPI specification, in a form that's directly usable by the OpenAPI Fetch client object.
The website describes this as a type checked API client, that's easily generated from an OpenAPI specification.
Implementing this is roughly two steps:
- Generate type definitions from your specification
- Configure the OpenAPI Fetch to use your specification
So, why does this article recommend a different approach?
By it's very nature an API client library is dealing with data retrieved from outside the application. TypeScript only performs compile-time type checking, and does not perform run-time data validation. For application safety there must be a step where data received from, or sent to, an external service is data validated.
Compile-time type checking is insufficient to ensure application safety.
Summary
It's relatively easy to auto-generate good type definitions, good data validation functions, and a good client API implementation, from an OpenAPI specification. But, it is difficult (on Node.js/TypeScript) to get all three at once.
We were able to implement auto-generated types using OpenAPI Codegen, and auto-generated data validation using OpenAPI to Joi or OpenAPI to Zod. Where the goal fell apart was auto-generating a client library that's integrated with the other two.
It was chosen to use OpenAPI Fetch for the API client framework. While it's a good tool for implementing a REST client from an OpenAPI specification, integrating it with data validation and OAuth2 token generation meant manually coding the integration.
That the API client framework is only partly auto-generated means you have overhead on each new release of the API.
Despite that, the result is good and will go a long way to ensure application safety.