Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

Intended Solutions for Intentional Limitations of Strongly Typed OpenAPI Client Generators

5.00/5 (3 votes)
1 Feb 2024CPOL8 min read 4.2K  
Use the codes generated by OpenApiClientGen in real world applications
Use the codes generated by OpenApiClientGen in real world applications: handling authentication and non-200 HTTP statuses. And build comprehensive integration test suites.

Background

As an application developer consuming JSON based Web APIs, you have been using some Swagger / OpenAPI tools (https://openapi.tools/) to generate client API codes if the service vendor does not provided a client API library but an OpenAPI definition file.

As the Web API and the respective OpenAPI definition files may evolve overtime, you need to update your client programs.

"Strongly Typed OpenAPI Client Generators" (OpenApiClientGen) is optimized for C# clients and TypeScript clients targeting jQuery, Angular 2+, Aurelia, AXIOS and Fetch API.

And this article is a complementary to the following articles:

Hopefully, such complementary may assist you to design clean and maintainable architecture of your real world solutions regarding security, load balancing, User Experience and Developer Experience.

Introduction

"Strongly Typed OpenAPI Client Generators" (OpenApiClientGen) has the following intentional limitations and by design by specification:

  1. Focus on strongly typed data models.
  2. For HTTP responses of API operations, generate codes only for "HTTP Status Code"=200 and "Content Type"="application/json".
  3. Skip definitions of HTTP headers of operation parameters.
  4. Skip definitions of Authentication and Authorization.

This article introduces a few intended solutions for these intentional limitations for the sake of writing clean application codes of complex business applications, with complex data models and workflows.

If the Web API or respective Swagger / OpenAPI definition is with very dynamic, versatile or weak-typed data, or the API operations support only text/xml, forms and binary payload, OpenApiClientGen is not for you, while there are plenty of Swagger / OpenAPI client codgen tools (https://tools.openapis.org/) that can handle such scenarios.

Comparing with other tools, OpenApiClientGen provides the following benefits to application programming:

  1. Strongly typed data matching to the server side data binding as much as possible. Since the initial release in 2020, it has supported decimal/currency/monetary data types, while Swagger / OpenAPI specifications prior to v3.1 had not provided inherent support for such data types.
  2. The footprints of generated source codes and the built images are much smaller.
  3. For TypeScript programmers, the generated codes satisfy TypeScript strict mode and Angular strict mode.
  4. For TypeScript programmers, generated HTTP client operations utilize what HTTP client request classes you have been using: jQuery.ajax of jQuery, HttpClient of Angular 2+, HttpClient of Aurelia, AXIOS and Fetch API.
  5. For Angular programmers, the NG2FormGroup plugin can generate strictly typed forms codes with validators.

And the intended limitations won't compromise comprehensive error handling needed in real world applications, for the sake of robust error handling, technical logging and User Experience, while the Internet is inherently unreliable.

Using the Code

The following TypeScript and C# code examples are to describe conceptual ideas of application programming and how the design intent of OpenApiClientGen may assist you to write clean application codes. For concrete solutions, you might still need to further study and refer to many well-written tutorial articles by the other more experienced programmers.

For Authentication

And it is presumed that you are familiar with HTTP interception in application programming for authentication.

Bearer Token

In this section about handling bearer token, an extended "Tour of Heroes" of Angular demonstrate how to centralize the handling of bearer token for each request made by the client API codes.

Generated Angular TypeScript Codes
TypeScript
    @Injectable()
    export class Heroes {
        constructor(@Inject('baseUri') private baseUri: 
          string = window.location.protocol + '//' + window.location.hostname + 
          (window.location.port ? ':' + window.location.port : '') + '/', 
          private http: HttpClient) {
        }

        /**
         * Get a hero. Nullable reference. MaybeNull
         * GET api/Heroes/{id}
         */
        getHero(id?: number | null, headersHandler?: () => 
           HttpHeaders): Observable<DemoWebApi_Controllers_Client.Hero | null> {
            return this.http.get<DemoWebApi_Controllers_Client.Hero | null>
               (this.baseUri + 'api/Heroes/' + id, 
               { headers: headersHandler ? headersHandler() : undefined });
        }

        /**
         * POST api/Heroes
         */
        post(name?: string | null, headersHandler?: () => HttpHeaders): 
                    Observable<DemoWebApi_Controllers_Client.Hero> {
            return this.http.post<DemoWebApi_Controllers_Client.Hero>
                   (this.baseUri + 'api/Heroes', JSON.stringify(name), 
                   { headers: headersHandler ? headersHandler().append
                   ('Content-Type', 'application/json;charset=UTF-8') : 
                     new HttpHeaders({ 'Content-Type': 
                                       'application/json;charset=UTF-8' }) });
        }

...
        
    /**
     * Complex hero type
     */
    export interface Hero {
        address?: DemoWebApi_DemoData_Client.Address;
        death?: Date | null;
        dob?: Date | null;
        emailAddress?: string | null;
        id?: number | null;

        /**
         * Required
         * String length: inclusive between 2 and 120
         */
        name?: string | null;
        phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;

        /** Min length: 6 */
        webAddress?: string | null;
    }

    /**
     * Complex hero type
     */
    export interface HeroFormProperties {
        death: FormControl<Date | null | undefined>,
        dob: FormControl<Date | null | undefined>,
        emailAddress: FormControl<string | null | undefined>,
        id: FormControl<number | null | undefined>,

        /**
         * Required
         * String length: inclusive between 2 and 120
         */
        name: FormControl<string | null | undefined>,

        /** Min length: 6 */
        webAddress: FormControl<string | null | undefined>,
    }
    export function CreateHeroFormGroup() {
        return new FormGroup<HeroFormProperties>({
            death: new FormControl<Date | null | undefined>(undefined),
            dob: new FormControl<Date | null | undefined>(undefined),
            emailAddress: new FormControl<string | null | undefined>
                          (undefined, [Validators.email]),
            id: new FormControl<number | null | undefined>(undefined),
            name: new FormControl<string | null | undefined>
                  (undefined, [Validators.required, 
                   Validators.maxLength(120), Validators.minLength(2)]),
            webAddress: new FormControl<string | null | undefined>
                  (undefined, [Validators.minLength(6), 
                   Validators.pattern('https?:\\/\\/(www\\.)?
                   [-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b
                   ([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)')]),
        });
    }
HTTP Interceptor

Major JavaScript/TypeScript frameworks like Angular and Aurelia have provided built-in way of HTTP interception so you can have a wholesale and predefined way of creating a HTTP client instance with updated credential. Though other JavaScript libraries like React or frameworks like VueJs do not provide built-in HTTP classes nor HTTP interception, it shouldn't be hard for you to craft one with the generated APIs utilizing AXIOS or Fetch API, as many tutorials online are available.

In tokeninterceptor.ts:

TypeScript
@Injectable()
export class TokenInterceptor implements HttpInterceptor {
    constructor(@Inject('BACKEND_URLS') private backendUrls: string[], 
                @Inject('IAuthService') private authService: IAuthService) {
        console.debug('TokenInterceptor created.');
    }

    intercept(request: HttpRequest<any>, httpHandler: HttpHandler): 
              Observable<HttpEvent<any>> {
        var requestNeedsInterceptor = this.backendUrls.find
                                      (d => request.url.indexOf(d) >= 0);
        if (!requestNeedsInterceptor) {
            return httpHandler.handle(request);
        }

        let refinedRequest: HttpRequest<any>;
        if (AUTH_STATUSES.access_token) {
            //The Request/Response objects need to be immutable. 
            //Therefore, we need to clone the original request before we return it.
            refinedRequest = request.clone({
                setHeaders: {
                    Authorization: `Bearer ${AUTH_STATUSES.access_token}`,
                    Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
                }
            });
        } else {
            refinedRequest = request.clone({
                setHeaders: {
                    Accept: 'application/json,text/html;q=0.9,*/*;q=0.8',
                }
            });
        }

        return httpHandler.handle(refinedRequest).pipe(catchError(err => {
            if ([401, 403].includes(err.status)) {
                console.debug('401 403');
                if (AUTH_STATUSES.refreshToken) {
                    return AuthFunctions.getNewAuthToken
                           (this.authService, refinedRequest, httpHandler);
                }
            }

            return Promise.reject(err);
        }));
    }

...

Typically, you would provide the generated API DemoWebApi_Controllers_Client.Heroes and the TokenInterceptor in httpServices.module.ts:

TypeScript
export function clientFactory(http: HttpClient) {
    return new namespaces.DemoWebApi_Controllers_Client.Heroes
                          (SiteConfigConstants.apiBaseuri, http);
}

@NgModule({})
export class HttpServicesModule {
    static forRoot(): ModuleWithProviders<HttpServicesModule> {
        return {
            ngModule: HttpServicesModule,
            providers: [
                {
                    provide: HTTP_INTERCEPTORS,
                    useClass: TokenInterceptor,
                    multi: true
                },
                
                {
                    provide: DemoWebApi_Controllers_Client.Heroes,
                    useFactory: clientFactory,
                    deps: [HttpClient],
                },
Application Codes

In hero-detail.component.ts:

TypeScript
@Component({
    selector: 'app-hero-detail',
    templateUrl: './hero-detail.component.html'
})
export class HeroDetailComponent implements OnInit {
    hero?: DemoWebApi_Controllers_Client.Hero;
    heroForm: FormGroup<HeroWithNestedFormProperties>;
    constructor(
        private heroService: DemoWebApi_Controllers_Client.Heroes,
        ...
    ) {
        this.heroForm = CreateHeroWithNestedFormGroup();
    }

    save(): void {
        const raw: DemoWebApi_Controllers_Client.Hero = 
                   this.heroForm.getRawValue(); // nested array included.
        this.heroService.put(raw).subscribe(
            {
                next: d => {
                    ...
                },
                error: error => alert(error)
            }
        );
    }

In your application codes, you don't see explicit codes of handing authentication, since authentication headers are handled by the provided TokenInterceptor.

PRODA and Medicare Online

Medicare Online is an Australian federal government's Web service launched in 2021 for Medicare, replacing the legacy adaptor way. It uses PRODA for basic authentication and authorization. This section is not intended to become a programming tutorial for Medicare Online or PRODA, but to inspire you for handling similar scenarios of complex key exchanges and dynamic header info in your application codes.

Remarks
  • It had taken me days to understand and setup a PRODA instance for my company as a client vendor, while PRODA itself had kept evolving before the official launch. Therefore, don't expect yourself to understand every technical details here, but seek inspiration for your own similar scenarios.

YAML for Bulk Bill Store Forward

parameters:
  Authorization:
    name: Authorization
    type: string
    required: true
    in: header
    description: JWT header for authorization
    default: Bearer REPLACE_THIS_KEY
  dhs-auditId:
    name: dhs-auditId
    type: string
    required: true
    in: header
    description: DHS Audit ID
    default: LOC00001
  dhs-subjectId:
    name: dhs-subjectId
    type: string
    required: true
    in: header
    description: DHS Subject ID
    default: '2509999891'
...

...

  /mcp/bulkbillstoreforward/specialist/v1:
    post:
      summary: This is the request
      parameters:
        - name: body
          required: true
          in: body
          schema:
            $ref: '#/definitions/BulkBillStoreForwardRequestType'
      responses:
        "200":
          description: successful operation
          schema:
            $ref: '#/definitions/BulkBillStoreForwardResponseType'
        "400":
          description: server cannot or will not process the request
          schema:
            $ref: '#/definitions/ServiceMessagesType'
      operationId: mcp-bulk-bill-store-forward-specialist@1-eigw
    parameters:
      - $ref: '#/parameters/Authorization'
      - $ref: '#/parameters/dhs-auditId'
      - $ref: '#/parameters/dhs-subjectId'
      - $ref: '#/parameters/dhs-messageId'
      - $ref: '#/parameters/dhs-auditIdType'
      - $ref: '#/parameters/dhs-correlationId'
      - $ref: '#/parameters/dhs-productId'
      - $ref: '#/parameters/dhs-subjectIdType'

Generated C# Client API Codes

C#
public async Task<BulkBillStoreForwardResponseType> BulkBillStoreForwardSpecialistAsync
   (BulkBillStoreForwardRequestType requestBody, 
    Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
    var requestUri = "mcp/bulkbillstoreforward/specialist/v1";
    using (var httpRequestMessage = new System.Net.Http.HttpRequestMessage
          (System.Net.Http.HttpMethod.Post, requestUri))
    {
    using (var requestWriter = new System.IO.StringWriter())
    {
    var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
    requestSerializer.Serialize(requestWriter, requestBody);
    var content = new System.Net.Http.StringContent
    (requestWriter.ToString(), System.Text.Encoding.UTF8, "application/json");
    httpRequestMessage.Content = content;
    if (handleHeaders != null)
    {
        handleHeaders(httpRequestMessage.Headers);
    }

    var responseMessage = await httpClient.SendAsync(httpRequestMessage);
    try
    {
        responseMessage.EnsureSuccessStatusCodeEx();
        var responseMessageStream = await responseMessage.Content.ReadAsStreamAsync();
        using (JsonReader jsonReader = new JsonTextReader
              (new System.IO.StreamReader(responseMessageStream)))
        {
        var serializer = JsonSerializer.Create(jsonSerializerSettings);
        return serializer.Deserialize<BulkBillStoreForwardResponseType>(jsonReader);
        }
    }
    finally
    {
        responseMessage.Dispose();
    }
    }
    }
}

As you can see, the generated codes skips the definitions of header parameters, and take care of only the business data payloads.

Application Codes
C#
BulkBillStoreForwardRequestType requestBody;
...

try
{
    var response = await NewClientWithNewIds(ids).BulkBillStoreForwardSpecialistAsync
                   (requestBody, (headers) =>
    {
        headers.TryAddWithoutValidation("dhs-subjectId", medicareNumber);
        headers.TryAddWithoutValidation("dhs-subjectIdType", "Medicare Card");
    });
...
}
catch (Fonlow.Net.Http.WebApiRequestException ex)
{
    HandleBadRequest(ids.TraceIdentity, ex);
    throw;
}

The generated API codes provide a custom headers handler, so you can adjust the HTTP headers for your app logic according to the published Medicare Web API manual. And in this case, you can easily provide a Medicare card number.

Wholesale Handling of Authentication

If a Web service is designed properly, a set of Web API functions should share the same way of providing credential as well as info of load balancing to the backend.

NewClientWithNewIds() encapsulates helper functions and factories for handing auth info on wholesale.

C#
...
override protected McpClient NewClientWithNewIds(McpTransactionIds ids)
{
    var client = NewHttpClientClientWithNewIds(ids, "mcp");
    return new McpClient(client, HttpClientSerializerSettings.Create());
}

override protected McpClient NewClientWithCorrelationId
                             (McpIdentity ids, string correlationId)
{
    var client = NewHttpClientWithCorrelationId(ids, correlationId, "mcp");
    return new McpClient(client, HttpClientSerializerSettings.Create());
}

protected HttpClient NewHttpClientWithCorrelationId
    (McpIdentity ids, string correlationId, string httpClientName)
{
    var client = httpClientFactory.CreateClient(httpClientName);
    client.DefaultRequestHeaders.TryAddWithoutValidation("orgId", ids.OrgId);
    client.DefaultRequestHeaders.TryAddWithoutValidation
                                 ("dhs-correlationId", correlationId);
    client.DefaultRequestHeaders.TryAddWithoutValidation
                                 ("proda-clientId", ids.ProdaClientId);
    return client;
}

A typical HTTP request is like this:

POST https://healthclaiming.api.humanservices.gov.au/claiming/ext-prd/mcp/patientverification/medicare/v1 HTTP/1.1
Host: healthclaiming.api.humanservices.gov.au
dhs-subjectId: 4253263768
dhs-subjectIdType: Medicare Card Number
Accept: application/json
orgId: 7125128193
dhs-correlationId: urn:uuid:MLK412708F2C7DDCE2598138
Authorization: Bearer eyJraWQiOiJCb...elkFglNxQ
dhs-productId: My company healthcare product 1.0
dhs-auditId: KKK41270
X-IBM-Client-Id: d58ab88eac7d983adb24ef00519faf61
dhs-auditIdType: Location Id
dhs-messageId: urn:uuid:3b39c9a6-c220-458c-bd5d-32dd47ec7738
traceparent: 00-fdc2b432189e291b7eb537c3a4777c4d-b0617b3e1b9fafcb-00
Content-Type: application/json; charset=utf-8
Content-Length: 275

{"patient":{"medicare":{"memberNumber":"4453263778","memberRefNumber":"2"},
 "identity":{"dateOfBirth":"1980-10-28","familyName":"Bean","givenName":"Tom","sex":"1"},
 "residentialAddress":{"locality":"Warner","postcode":"4500"}},
 "dateOfService":"2022-01-23","typeCode":"PVM"}

For Non-200 HTTP Responses

Application Codes

C#
try
{
    var response = await NewClientWithNewIds(ids).BulkBillStoreForwardSpecialistAsync
                   (requestBody, (headers) =>
    {
        headers.TryAddWithoutValidation("dhs-subjectId", medicareNumber);
        headers.TryAddWithoutValidation("dhs-subjectIdType", "Medicare Card");
    });
...
}
catch (Fonlow.Net.Http.WebApiRequestException ex)
{
    HandleBadRequest(ids.TraceIdentity, ex);
    throw;
}

Upon non-2xx HTTP responses, the generated client API function may throw System.Net.Http.HttpRequestException or Fonlow.Net.Http.WebApiRequestException which exposes more response info for detailed error handling.

Points of Interest

While the integration test suites of OpenApiClientGen have covered 2,000 Swagger / OpenAPI definition files with over 5,000 test cases, I have inspected only a few dozen definition files that contain authentication meta. I doubt whether Swagger / OpenAPI v2, v3, vX could possibly cover wide variety of "standard" authentication mechanisms, leaving alone special behaviours of each implementation on the service side, therefore I doubt whether a code generator could generate practically useful auth codes that could be used directly in application programming. It is better-off to let application programmer write the auth codes, as long as the overall design makes each client API calls in the app code look simple and clean.

Those Swagger / OpenAPI Definitions Unfriendly to C# and TypeScript

There are at least a few dozen popular programming languages for developing Web API. Backend programmers may have declared identifiers of the service side unfriendly to C# and TypeScript on the client side:

  • Conflict or collision against reserved words of C# and TypeScript
  • Versatile literal string for enum
  • Names inappropriate for type name, parameters and function names, etc.
  • ...

OpenApiClientGen renames those identifiers without impacting the request payload and the handling of the typed responses.

Hints

  • Handling enum is rather tricky. ASP.NET (Core) Web API by default can handle enum as string or as integer. If you are sure that the Web API expects enum as string only, just turn on option "EnumToString".
  • Some yaml files may have multiple operationIds not unique, or the combination of operationId+tags not unique, when OpenApiClientGen, by default, use operationId+tags for client API function names, then you may use option "ActionNameStrategy.PathMethodQueryParameters".

Non "application/json" Payloads

Currently, OpenApiClientGen v3 still generates client API codes, however obviously the client functions won't be talking to the backend correctly, so you need to be careful not to use those client functions in your app codes. And in v3.1, there will be a switch for skipping generating codes for such operations.

Doggy Swagger / OpenAPI Definitions

The quality of your client application depends on the quality of the Web API and the quality of the generated codes. To ensure the basic quality of the generated codes, the integration test suites cover over 1,700 OpenAPI v3 definitions and over 615 Swagger v2 definitions: generate codes, compile with C# compiler and TypeScript/Angular compiler.

However, I have come across over 100 doggy swagger / OpenAPI definitions, some of which have come from some prominent Web service vendors. Typical dirty or doggy designs in some YAML files:

  1. In inherited data models, a property is declared multiple times. C# compiler or TypeScript code analysis may raise a warning. However, you understand the nasty effect of having such inheritance. And for Angular Reactive Forms which does not support inheritance, this will result in duplicated Form Controls and error.
  2. Wrong data constraints. For example, type integer with default "false"; using maximum rather than maxItems for array size limit.
  3. ...

If you happen to be using these Web services, you may two approaches:

  1. Use the other Swagger / OpenAPI tools that may tolerate the dirty or doggy designs.
  2. Talk to the service vendor.

Remarks

  • Obviously, having the generated codes being successfully compiled is far from enough. It is important to write an integration test suite to assure the generated codes can talk correctly to the backend.

Integration Test Suite for the Generated Codes

In addition to writing integration test suites for your application codes, you should write an integration test suite upon the Web API, using the generated client API codes and serving such purposes:

  • Understand and learn the behaviour and the quality of the Web API.
  • Isolate the problem areas when having a bug report during development and production.

History

  • 1st February, 2024: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)