AWS APIGateway × OpenAPI (2. Proxy)

2022-07-26

thumbnail

I have been working on a library that integrates an OpenAPI definition with a REST API definition on the CDK. This is the second blog post of the series that will walk you through the development of the library.

Background

In the first blog post of this series, we left the following challenge,

How can we achieve compatibility with RestApi?

  • If we write a subclass or a wrapper for RestApi, we cannot directly manipulate what RestApi instantiates as subsidiaries; e.g., Resources, Methods. How can we extend the subsidiary entities?
  • If we do not reuse RestApi, it will be really tough to achieve compatibility.

Since it is too hard not to reuse RestApi, I am going to write a subclass for it.

Defining interfaces

Before extending the AWS Cloud Development Kit (CDK) API, let us define minimal interfaces. Please note that I have excerpted and modified the interfaces and code shown in the following sections from my library for simplicity. You can find their full definitions in my GitHub repository.

IRestApiWithSpec

IRestApiWithSpec is the extension of IRestApi.

interface IRestApiWithSpec extends IRestApi {
  readonly root: IResourceWithSpec;
  // ... other more properties
}

IResourceWithSpec

IResourceWithSpec is the extension of Resource*.

interface IResourceWithSpec extends Resource {
  addResource(pathPart: string, options?: ResourceOptionsWithSpec): IResourceWithSpec;
  addMethod(httpMethod: string, target?: Integration, options?: MethodOptionsWithSpec): Method;
  // ... other more properties
}

* To correctly extend IResource.addResource, IResourceWithSpec has to implement Resource rather than IResource. On the other hand, IRestApiWithSpec.root has to implement IResource instead of Resource, so the definition root: IResourceWithSpec in IRestApiWithSpec is too specific. However, it should not matter because the public interface of Resource, and IResource are the same as far as I know.

ResourceOptionsWithSpec

ResourceOptionsWithSpec is the extension of ResourceOptions.

interface ResourceOptionsWithSpec extends ResourceOptions {
  defaultMethodOptions?: MethodOptionsWithSpec;
}

MethodOptionsWithSpec

MethodOptionsWithSpec is the extension of MethodOptions.

interface MethodOptionsWithSpec extends MethodOptions {
  requestParameterSchemas?: { [key: string]: BaseParameterObject };
  // ... other more properties
}

BasePrameterObject is borrowed from an external library OpenApi3-TS[1].

I initially intended to override the definition of MethodOptions.requestParameters like the following (see the example in the last blog post),

requestParameters?: { [key: string]: boolean | BaseParameterObject };

But this attempt has failed because it would make MethodOptionsWithSpec unassignable to MethodOptions. So I have introduced a new property requestParameterSchemas as a workaround.

Extending RestApi

Extending RestApi is straightforward. Just use the language feature (extends) and override properties of RestApi.

class RestApiWithSpec extends RestApi implements IRestApiWithSpec {
  readonly root: IResourceWithSpec;

  constructor(scope: Construct, id: string, props: RestApiWithSpecProps) {
    super(scope, id, props);
    // how do we implement `root`?
  }
}

I omit the details of RestApiWithSpecProps for simplicity. Please refer to my GitHub repository if you are interested in it.

Here, the question is how to implement root. Wrapping root of the superclass (RestApi) sounds good. So how do we wrap it? Write a wrapper class that just forwards most requests to root of RestApi? That will involve a lot of boilerplate. Then Proxy should better meet our needs.

Proxying root

Writing a Proxy for root is fairly easy. All you have to do is implement the get function of a handler object.

// root: IResource
const rootWithSpec: IResourceWithSpec = new Proxy(root, {
  get: (target, prop, receiver) => {
    switch (prop) {
      case 'addResource':
        return (pathPart, options) => {
          // ... create and wrap again a new resource
        };
      case 'addMethod':
        return (httpMethod, target, options) => {
          // ... process options
        };
      default:
        return Reflect.get(target, prop, receiver);
          // TypeScript complained if I did `Reflect.get(...arguments)`
    }
  }
});

Unfortunately, TypeScript does not recognize the proxied object as IResourceWithSpec in the above code and ends up with an error. Because the default TypeScript definition of the Proxy constructor is something like the following,

declare global {
  interface ProxyConstructor {
    new <T extends object>(target: T, handler: ProxyHandler<T>): T; // Proxy of T is still T
  }
}

To address this issue, we can extend the definition of the Proxy constructor so that it can produce a type different from that of target. This StackOverflow post[2] exactly answers it. So adding the following declaration solves the TypeScript error.

declare global {
  interface ProxyConstructor {
    new <T extends object, U extends object>(target: T, handler: ProxyHandler<T>): U; // Proxy of T may be U
  }
}

Extending Resource

Every time we create a Resource, we have to wrap it with a Proxy as described in the section "Proxying root." So I have introduced a factory method augmentResource that can wrap any existing IResource with the features of IResourceWithSpec. This factory method can also be applied to root.

class ResourceWithSpec {
  static augmentResource(restApi: IRestApiWithSpec, resource: IResource, parent?: IResourceWithSpec): IResourceWithSpec {
    const resourceWithSpec: IResourceWithSpec = new Proxy(resource, {
      get: (target, prop, receiver) => {
        switch (prop) {
          case 'addResource':
            return (pathPart, options) => {
              return augmentResource(
                restApi,
                resource.addResource(pathPart, options),
                resourceWithSpec
              );
            };
          // ... handle other properties
        }
      }
    });
    return resourceWithSpec;
  }
}

The above code has been simplified; you can find the actual definition in my GitHub repository.

Wrap-up

In this blog post, I have introduced Proxy to facilitate the extension of the CDK API. I also have shown a trick to circumvent a TypeScript error about Proxy. In an upcoming blog post, we will tackle the challenge of when we should output an OpenAPI definition file.

Reference

  1. OpenApi3-TS

    TypeScript bindings of the OpenAPI 3 specification itself.

  2. How to use Proxy with a different type than T as argument? - Answer