DEV Community

Cover image for Never Write OpenAPI Specifications Again by using TypeScript
Stefan  🚀
Stefan 🚀

Posted on • Originally published at wundergraph.com

Never Write OpenAPI Specifications Again by using TypeScript

We're all aware of the problem of keeping your API documentation in sync with the implementation of your API. There are multiple ways to solve this problem, but so far all of them take a lot of time and effort to maintain.

In this article, I will show you a new approach to API development that will make your life 10x easier. Just write your API in TypeScript and let the compiler do the work for you. Don't ever worry about writing OpenAPI specifications again.

The Problem of Keeping Your API Documentation in Sync

Before we jump to the solution, let's discuss the two most common ways to solve the problem of keeping your API documentation in sync with the implementation of your API.

Schema-First Approach

The first approach is called the schema-first approach. In this approach, you write an OpenAPI specification and then generate a server stub from it. The server stub can then be used to implement your API.

The main advantage of this approach is that you can be 100% sure that your API implementation is in sync with the API documentation. The downside is that you have to write the specification. It's tedious work compared to "TypeScript First" as we will see later.

Additionally, the tooling to generate server stubs from OpenAPI specifications is not great. You cannot easily map OpenAPI constructs to every programming language. So, depending on what language or framework you want to use, it might limit you in how much of the OpenAPI specification you can actually use.

Code-First Approach

The second approach is called the code-first approach. In this approach, you usually write code and annotate it with OpenAPI constructs/tags/annotations so that a tool can generate an OpenAPI specification from it.

The main advantage of this approach is that you don't have to write the specification, at least not directly.

However, you now have to use the annotations and make sure that what you write in your code a way that is compatible with the annotations. It works, but it's not great.

Summarizing the existing approaches, we can see that there's a lot of room for improvement. Simply search online for "TypeScript OpenAPI Server" and you will see that there's no simple solution that just works, until now!

TypeScript First API Development

Enter TypeScript First API Development. In this approach, you write TypeScript API handlers, and you're done. The compiler will generate an OpenAPI specification for you. You don't write any specifications, you don't need to annotate your code, and you don't need to generate server stubs.

Let's see how this works.

1. Init a New Project

npx create-wundergraph-app my-project -E simple \ &&
cd my-project && npm i && npm start
Enter fullscreen mode Exit fullscreen mode

2. Create a New API Handler

// .wundergraph/operations/hello_world.ts
import { createOperation, z } from '../generated/wundergraph.factory';

export default createOperation.query({
   input: z.object({
      hello: z.string(),
   }),
   handler: async ({ input }) => {
      return {
         world: input.hello,
      };
   },
});
Enter fullscreen mode Exit fullscreen mode

Save this file and the compiler will generate an OpenAPI specification for you. That's it! We're done! All we did was write a TypeScript function with an input definition and a handler.

Check out the generated OpenAPI specification:

.wundergraph/generated/wundergraph.openapi.json.

{
  "openapi": "3.1.0",
  "info": {
    "title": "WunderGraph Application",
    "version": "0"
  },
  "servers": [
    {
      "url": "http://localhost:9991"
    }
  ],
  "paths": {
    "/operations/hello_world": {
      "get": {
        "operationId": "Hello_world",
        "parameters": [
          {
            "name": "hello",
            "description": "Type: string",
            "in": "query",
            "required": true,
            "allowEmptyValue": false,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Success",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "world": {
                      "type": "string"
                    }
                  },
                  "required": [
                    "world"
                  ]
                }
              }
            }
          },
          "400": {
            "description": "Invalid input",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/InvalidInputError"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "InvalidInputError": {
        "type": "object",
        "properties": {
          "message": {
            "type": "string"
          },
          "input": {},
          "errors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "propertyPath": {
                  "type": "string"
                },
                "invalidValue": {},
                "message": {
                  "type": "string"
                }
              },
              "required": [
                "propertyPath",
                "invalidValue",
                "message"
              ]
            }
          }
        },
        "required": [
          "message",
          "input",
          "errors"
        ]
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

We can now use curl or any other client to test our API.

curl http://localhost:9991/operations/hello_world?hello=world
Enter fullscreen mode Exit fullscreen mode

3. Deploy Your API to the Cloud and start sharing it!

Last and final step. Push your code to GitHub, connect your repository to WunderGraph Cloud , and deploy your API to the cloud in less than a minute.

You'll get a public URL, can assign a custom domain, and you get analytics per endpoint with a generous free tier.

How does it work?

So, how is it possible that we can write pure TypeScript code and the compiler will generate the OpenAPI specification for us?

WunderGraph looks at the .wundergraph/operations directory and checks if a file has a default export that uses the createOperation factory. If it does, it will use "esbuild" to transpile the file and dynamically load it into WunderGraph's fastify server as an endpoint.

At the same time, we'll use the TypeScript compiler to introspect the type of the input object as well as the return type of the handler. As the input is defined using the zod library, we can use a plugin that compiles the zod schema into a JSON Schema, which is then used to generate the "input" part of the OpenAPI specification.

For the output part, we're using the TypeScript compiler to introspect the AST of the handler function and extract a JSON Schema from it. Combined with the input JSON Schema, we can generate the full OpenAPI specification for the endpoint.

Conclusion

Using the specification-first approach can be very useful when you want to share and discuss your API design before you start implementing it.

However, there are use cases where you want to move fast, iterate quickly, and don't want to be slowed down by writing specifications, but still want to have a specification that you can share with your team and clients. In these cases, the TypeScript First approach is a great middle ground.

Top comments (0)