/stl-api

Stainless full-stack API Framework

Primary LanguageTypeScript

Stainless: a framework for robust & polished REST APIs

Stainless helps you ship quality, typesafe REST APIs from any TypeScript backend.

You declare the shape and behavior of your API in one place, and get an OpenAPI spec, docs, and typed frontend client without a build step.

You can use it as a pluggable, batteries-included web framework for APIs (managing auth, pagination, observability, etc) or sprinkle it on top of your existing API in any framework for better OpenAPI support and/or full-stack typesafety.

You can also opt into Stainless's Stripe-inspired pristine API design conventions and get rich pagination, consistent errors, field inclusion & selection, and (WIP) normalized caching on the frontend for free.

Stainless draws inspiration with gratitude from tRPC, FastAPI, GraphQL/Relay, and (heavily) from the internal API Framework we worked on at Stripe.

Note This project is currently in its alpha stage of development. Features may be changed or removed at any time without warning, and production use is not recommended. Hobbyists welcome!

For example:

// server.ts
import { Stl } from "stainless";
import { makePrismaPlugin } from "@stl-api/prisma";
import { makeNextPlugin } from "@stl-api/next";
import { makeNextAuthPlugin } from "@stl-api/next-auth";
import { authOptions } from "~/pages/api/auth/[...nextauth]";
import prisma from "~/libs/prisma";
import { z } from "stainless";

const plugins = {
  next: makeNextPlugin(),
  nextAuth: makeNextAuthPlugin({ authOptions }),
  prisma: makePrismaPlugin(),
};

export const stl = new Stl({
  plugins,
});

const User = z.object({
  id: z.string(),
  name: z.string(),
});

const update = stl.endpoint({
  endpoint: "POST /users/:id",
  description: "Update a user. Currently only updating the name is supported.",
  authenticated: true,
  response: User,

  path: z.object({
    id: z.string(),
  }),
  body: z.object({
    name: z.string(),
  }),

  async handler({ id, name }) {
    return await prisma.users.updateOne(id, { name });
  },
});

export const api = stl.api({
  openapi: {
    endpoint: "GET /api/openapi",
  },
  resources: {
    users: stl.resource({
      actions: {
        update,
      },
      models: {
        User,
      },
    }),
  },
});

// client.ts
import { createClient } from "stainless";
import type { api } from "./server";

const client = createClient<typeof api>("http://localhost:3000/api");

// params are fully typed:
const user = await client.users.update("id", { name: "my new name" });
// user object is fully typed.

// A full OpenAPI spec is available by default at "get /openapi":
const openapi = await client.getOpenapi();
console.log(openapi.paths["/users/:id"].post);
console.log(openapi.components.schemas.User);

Getting started

See stainless package docs to get started!

Packages

Pristine

Pristine is an API Standard by Stainless, providing opinions on API design so teams don't have to bikeshed, and so tools can expect consistent API shapes.

Following the Pristine Standard helps your API offer an interface like Stripe's, with best-practices baked in. Like the Relay standard for GQL, Pristine can also help tooling like frontend clients cache data, paginate requests, handle errors, and so on.

Pristine Conventions

Here is a list of Pristine API design conventions:

Using Stainless in an existing codebase

If you'd like a maintainable way of declaring your OpenAPI spec in TypeScript, right alongside your application code, and getting great docs, end-to-end typesafety, and backend API client libraries (SDKs), you can adopt the stainless library gradually in minimally-invasive ways.

For example, in an Express app, you can add annotations near a handler to get an OpenAPI spec and client:

// app/routes/users.ts

const User = z.object({
  id: z.string(),
  name: z.string(),
});

const create = stl.endpoint({
  endpoint: "POST /users",
  response: User,
  body: z.object({ name: z.string() }),
});

app.post("/users", async (req, rsp) => {
  const user = await db.users.create({ name });
  rsp.status(200).json(user);
});
You'll also need a small amount of server code
// app/api.ts
const users = stl.resource({
  models: { User },
  actions: {
    create,
  },
});

const api = stl.api({
  resources: {
    users,
  },
});

// and voila, you get an OpenAPI spec!
app.get("/openapi", (req, rsp) => {
  rsp.json(api.openapi.spec);
});
For typesafety and validation of parameters, you can also sprinkle in param parsing, response generation, and more:
app.post("/users", async (req, rsp) => {
  const { name } = create.validateParams(req);

  const user = await db.users.create({ name });

  rsp.send(create.makeResponse(user));
});

Doing this helps TypeScript ensure that your OpenAPI spec is an accurate reflection of your runtime behavior. It can also help return consistent response shapes and validation error messages to the user.

Note that validateParams raises BadRequestError if params don't match.

To handle errors like this, add middleware:

app.use((err, req, rsp, next) => {
  if (err instanceof stl.Error) {
    rsp.status(err.statusCode).send(stl.makeError(err));
  }
  // …
});

stl.makeError is will return a JSON object with type, message, and other information. (TODO add/encourage things like request ID's…)