Using TypeSpec to Design APIs

Using TypeSpec to Design APIs

Posted in

Microsoft recently sent shockwaves through the API industry when they announced TypeSpec, a new API definition language and platform that’s especially useful for API-first development. It’s inspired by two other programming languages created by Microsoft: C# and TypeScript, their version of JavaScript. Many of TypeSpec’s features are entirely new, while others are built around familiar concepts and principles. This makes TypeSpec ideal for seasoned API developers as well as new API developers.

TypeSpec might be new to API developers, but Microsoft has already been using it internally for a number of years. It was created to address issues with deploying resources at scale in Azure. One outcome of this is TypeSpec’s ability to create components that can be reused across services. It’s also intended to be lightweight, familiar, and easy to use. Its terse syntax will be particularly interesting to API developers, as it’s meant to make authoring API specifications and integrating with existing OpenAPI tools as easy as possible.

To show you what TypeSpec is capable of, we’ve put together a tutorial to help you get started designing APIs with TypeSpec for yourself.

Using TypeSpec to Design APIs

To start, create a directory in your programming environment for this project. We’ve just called ours TypeSpec. Now, install TypeSpec. Run the following code:

npm install -g @typespec//compiler

This installs the TypeSpec compiler globally. You’ll need to be running npm 7+. To update npm, run npm install -g npm.

Create Your First TypeSpec Project

Once the TypeSpec compiler is installed, we can create our first TypeSpec project. Run the following command:

tsp init

After initializing the project, you’ll be prompted with a few questions. Choose Generic REST API and @typespec/openapi3 library. Then, install the dependencies by inputting npm install.

Finally, compile the initial file by running npx tsp compile in the command prompt. You should be left with the following file structure:

main.tsp
tspconfig.yaml
package.json
node_modules/
tsp-output/
  @typespec/
    openapi3/
      openapi.yaml

Now we’re ready to start creating our API using TypeSpec.

Services and Servers

In TypeSpec, a service is a namespace containing all of the operations and top-level metadata like service name and version. The server is the host for the service. Servers are also able to accept additional parameters. Here’s an example of how to implement a service and server in TypeSpec.

using TypeSpec.Http;
using TypeSpec.Rest;

/**
 * This is a sample server Petstore server.
 */
@service({
  title: "Pet Store Service",
})
@server("https://example.com", "Single server endpoint")
namespace PetStore;

After you’ve inserted that code into the main.tsp file, you can compile the code using the tsp compile . command. Once it’s finished, you should see a tsp-output folder. Inside this folder, you should see a sub-directory for @typespec, which contains an @openapi3 directory. You’ll see an openapi.yaml file, which converts everything from your TypeSpec code into an OpenAPI specification.

This should give you some ideas of the power of TypeSpec and its functionality. With a simple command, you can create a fully functional API straight from your code editor and transform it into a working OpenAPI specification. This makes integrating TypeSpec with existing OpenAPI tools incredibly easy, which is one of the main reasons it exists in the first place.

At this point, you’ve got a working TypeSpec API. We’ll delve into a few more of its features to give you a better idea of how TypeSpec works and what it’s doing.

Routes and Resources

In TypeSpec, anything that can be identified with a URL and manipulated with an HTTP method is known as a resource. The operations for a resource are typically grouped under a namespace. These are specified using the @Route decorator and specifying the path to that resource.

@route("/pets")
namespace Pets {
}

Now, add a Pet model to the namespace.

model Pet {
  @minLength(100)
  name: string;

  @minValue(0)
  @maxValue(100)
  age: int32;

  kind: "dog" | "cat" | "fish";
}

Finally, operations can be defined using @get, @head, @post, @put, @patch, or @delete decorators. Let’s add a @get decorator to our Pets namespace.

@route("/pets")
namespace Pets {
  op list(): Pet[];

  // or you could also use
  @get op listPets(): Pet[];
}

The end result should look something like this:

@route("/pets")
namespace Pets {
model Pet {
  @minLength(100)
  name: string;

  @minValue(0)
  @maxValue(100)
  age: int32;

  kind: "dog" | "cat" | "fish";
}
@get op listPets(): Pet[];
}

Generate Routes Automatically

You can generate routes automatically from the code rather than manually specifying each route using @autoroute decorators. @Path parameters must be accompanied by a @segment decorator that defines the preceding path. Here’s an example:

model CommonParameters {
  @path
  @segment("tenants")
  tenantId: string;

  @path
  @segment("users")
  userName: string;
}

model User {
  name: string;
}
@error
model Error {
  message: string;
}

@autoRoute
interface UserOperations {
  @get
  getUser(...CommonParameters): User | Error;

  @put
  updateUser(...CommonParameters, user: User): User | Error;
}

This results in both operations ending up at the following endpoint:

/tenants/{tenantId}/users/{userName}

Path and Query Parameters

Operations can be modified using query parameters, as well. For example, if you wanted to add pagination and a read operation to the Pets resource, it would look like this:

@route("/pets")
namespace Pets {
  op list(@query skip?: int32, @query top?: int32): Pet[];
  op read(@path petId: int32): Pet;
}

Headers

A model’s properties and parameters can be defined using the @header decorator. The header name gets its name from a parameter. If it’s not specified, it’s inferred from the parameter or property name. For example, if you wanted to add a header that accepted eTag support to your read operation, you would insert the following:

@route("/pets")
namespace Pets {
  op list(@query skip: int32, @query top: int32): Pet[];
  op read(@path petId: int32, @header ifMatch?: string): {
    @header eTag: string;
    @body pet: Pet;
  };
  @post
  op create(@body pet: Pet): {};
}

Request and Response Bodies

Requests and responses can be specified using @body decorators. For example, if you wanted to create an endpoint for adding a pet, it would like this:

@route("/pets")
namespace Pets {
  op list(@query skip: int32, @query top: int32): {
    @body pets: Pet[];
  };
  op read(@path petId: int32): {
    @body pet: Pet;
  };
  @post
  op create(@body pet: Pet): {};
}

If a @body is not specified, the parameters not allocated to @header, @query, or @path are considered the body.

Status Codes

There’s also a @statusCode decorator if you want to specify a status code for a resource or operation. For example, if you wanted to add a 404 to the read endpoint, you would include the following code after the op read operation.

| {
    @statusCode statusCode: 404;
  };
  op create(@body pet: Pet): {
    @statusCode statusCode: 204;
  };
}

After inputting that code, you should be left with something like the following:

import "@typespec/http";
import "@typespec/rest";
import "@typespec/openapi3";

@service({
  title: "Widget Service",
})
namespace DemoService;

@route("/pets")
namespace Pets {
model Pet {
  @minLength(100)
  name: string;

  @minValue(0)
  @maxValue(100)
  age: int32;

  kind: "dog" | "cat" | "fish";
}
op list(@query skip: int32, @query top: int32): {
    @statusCode statusCode: 200;
    @body pets: Pet[];
};
  op read(@path petId: int32, @header ifMatch?: string): {
  @statusCode statusCode: 200;
    @header eTag: string;
    @body pet: Pet;
  };
  @post
  op create(@body pet: Pet): {};
}


using TypeSpec.Http;
using TypeSpec.Rest;

model Widget {
  @key id: string;
  weight: int32;
  color: "red" | "blue";
}

@error
model Error {
  code: int32;
  message: string;
}

interface WidgetService extends Resource.ResourceOperations<Widget, Error> {
  @get @route("customGet") customGet(): Widget;
}

If you compile that code and open openapi.yaml, you should see a fully defined OpenAPI specification generated solely from your TypeSpec code with a simple command.

Final Thoughts on Using TypeSpec

TypeSpec stands to be a real game-changer in the API industry. It’s likely to become increasingly important and popular with the rise of API-first design. This also means TypeSpec could play an essential part in the interaction between AI and APIs, as an AI could easily decipher, design, or implement an API solely with a TypeSpec specification.

TypeSpec is also simply solving existing problems in API design. Many developers find creating OpenAPI specifications unwieldy and inefficient, and not everyone knows how to read or interpret them. The ability to create and interpret clean, clear API specifications from code and the command line will likely become increasingly important as the industry continues to shift and evolve.