Library / SDK
graphql-hive/SOFA avatar
graphql-hive/SOFA

sofa-api generates a REST API from a GraphQL schema

The best way to create REST APIs - Generate RESTful APIs from your GraphQL Server

1,119 stars90 forksTypeScriptMIT

At a glance

What is it?
Sofa reads your GraphQL schema and turns the queries, mutations and subscriptions into REST routes, with nested types exposed as their own endpoints instead of being inlined. It is a The Guild project distributed as sofa-api, it plugs into Express or a bare http server, and it is still on a 0.x version line.
Who is it for?
sofa-api is worth adopting if you already have a GraphQL schema and need a REST surface for partners or clients who will not send a GraphQL query, because the routes, the argument mapping and the OpenAPI definition are all derived from the schema you have rather than written twice. It is the wrong choice if your API is naturally REST, if you need a full CRUD surface with filtering and pagination that GraphQL does not model, or if you cannot accept a 0.x dependency.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 43 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

The schema is the only input

Sofa takes your GraphQL schema, looks for available queries, mutations and subscriptions and turns all of that into a REST API. That is the whole mechanism, and it is the reason the library is short.

The documentation's example is a small Chat type and a Query type with four fields, and the routes that come out are four lines:

code
GET /chat/:id
GET /chats
GET /me
GET /recent-chats

The mapping is unsurprising for the first level. A query that takes an id becomes a path parameter, a list query becomes a collection, and the field name is kebab cased, which is why recentChats becomes recent-chats. The dependencies in the package make that explicit, with param-case and title-case doing the name conversion.

The interesting part is what happens below the first level, and the concept the project calls Models.

The idea behind Models is to not expose full objects in every response, especially if it is nested, not first-level data. If you fetch a list of chats, you do not want every message inlined in the response, you want them as IDs or links, and those messages have to have their own endpoint. The documentation notes that in REST you would probably call them Resources.

The requirements for opting a type in are small and checkable. You need two queries, one that exposes a list with no non-optional arguments and one that fetches a single entity with an id field as an argument, and the model itself has to have an id field. Those are the only requirements, and the example shows a Message type with an id and other fields, alongside messages and message(id: ID) queries.

So the rule is that a type becomes a resource if you write the two queries for it, and a type that is only ever reached through another type's field gets inlined.

Models decide what a response contains

The Models rule is the design decision with the biggest consequences, so it is worth being precise about what it produces.

For a first-level query, the response contains the type in full, with the nested fields resolved as they are declared. For a nested type that has been declared as a Model, the nested field in the response becomes an identifier or a link rather than the object, and the object is fetched from its own endpoint.

That has an effect on clients that a team should agree on before shipping, because the response shape for the same field differs depending on whether the type was declared as a Model. A client that reads chats gets a list of chat objects whose messages are identifiers, and a client that reads one message gets the message.

There is an escape hatch for the cases where the full object is the right answer. The ignore option takes a list of patterns, and the pattern is Type.field or Type. The example passes ignore: ['Message.author'], and the effect is that whenever Sofa tries to resolve an author of a message, instead of exposing an ID it passes whole data.

There is also a depth limit, and its default matters. Sofa prevents circular references by default, but only one level deep, and you can set the depthLimit option to any number. The example sets it to 2.

Taken together, these three options are how you shape a generated API without hand writing routes: the Models rule sets the default, ignore overrides it for named fields, and depthLimit controls how far the resolver walks. The documentation presents them as separate knobs, and in practice they interact, so a team should decide the shape of the API once and write it down, because the generated routes are only as consistent as those three settings.

Installing it and mounting it in front of your schema

The package is distributed as sofa-api and installs like any other dependency:

bash
yarn add sofa-api
# or
npm install sofa-api

The integration is a middleware function. The documentation gives a complete example with no dependency on frameworks, using the Node http module:

js
import http from 'http';
import getStream from 'get-stream';
import { useSofa } from 'sofa-api';

const server = http.createServer(
  useSofa({
    basePath: '/api',
    schema,
  })
);

And the same middleware mounted in Express:

js
import { useSofa } from 'sofa-api';
import express from 'express';

const app = express();

app.use(
  '/api',
  useSofa({
    basePath: '/api',
    schema,
  })
);

// GET /api/users
// GET /api/messages

The basePath option is the path the generated routes are served under, and in the Express example it is set to the same value as the mount point, so the resulting URLs are the mount point plus the generated path. The schema option is the executable GraphQL schema with your resolvers, and the rest of the configuration is optional.

The only peer dependency is graphql itself, at 15 or 16, and the package metadata marks the module as an ES module with a CommonJS build alongside it, so it drops into either kind of project. The other runtime dependencies are small utilities, a fetch implementation, the GraphQL tools helpers, an OpenAPI types package and a couple of case conversion libraries.

Context, and replacing the execution phase entirely

Two options turn Sofa from a simple generator into something you can adapt, and they are the reason to use it rather than writing a converter by hand.

The first is context. In order for Sofa to resolve operations based on a Context, you need to be able to provide one, and the example does it with an async function that receives the request:

ts
api.use(
  '/api',
  useSofa({
    basePath: '/api',
    schema,
    async context({ req }) {
      return {
        req,
        ...yourContext,
      };
    },
  })
);

The context is what carries the authenticated user into the resolvers, so a REST call and a GraphQL call end up executing the same resolver with the same context object. You can pass a plain object or a function.

The second is the execute phase, and it is the more interesting one. By default Sofa uses the graphql function from graphql-js to turn an operation into data, but the documentation says it is straightforward to pass your own logic, thanks to that you can even use a remote GraphQL server with Fetch or through Apollo Links:

ts
api.use(
  '/api',
  useSofa({
    basePath: '/api',
    schema,
    async execute(args) {
      return yourOwnLogicHere(args);
    },
  })
);

That distinction is bigger than it looks. With the default, Sofa resolves against your local schema in process. With a custom execute, Sofa becomes a REST to GraphQL gateway, and the schema you pass is the contract while the data comes from somewhere else, possibly a remote GraphQL endpoint, possibly an Apollo Link chain, possibly a completely different data source you write yourself.

So the same package covers two deployments. If you already run GraphQL in production, pointing Sofa at it with a custom execute is the whole use case. If you are mid migration and want REST now, the default local execution against your existing schema is the whole use case.

Subscriptions run as webhooks

GraphQL subscriptions are the part of the specification with no obvious REST equivalent, and Sofa handles them with a small webhook API instead of trying to map them onto resources.

There are three endpoints. POST /webhook starts a subscription, DELETE /webhook/:id stops it, and POST /webhook/:id updates it.

To start a subscription, the request body carries the subscription name, which matches the name in the GraphQL schema, the variables to run it with, which are optional, and the url of the webhook receiving endpoint. The response is a unique id, which is the identifier of the started subscription:

json
{
  "id": "SUBSCRIPTION-UNIQUE-ID"
}

Stopping it means passing that id to DELETE /webhook/:id. Updating it looks much like starting one, the body carries variables, and the response is a new id, again a SUBSCRIPTION-UNIQUE-ID.

The example in the documentation uses a Subscription type with an onBook field, and starts it with:

json
{
  "subscription": "onBook",
  "variables": {},
  "url": "https://app.com/new-book"
}

The design is worth a second look, because it is a deliberate trade. A REST client cannot hold an open connection for a subscription, so instead Sofa registers a webhook with a URL of the client's choosing and pushes events to it. The client gets an id it can use to stop or update the registration later.

The cost is that the server holds the subscription open, so this is state on the server and it needs a place to keep the ids. For a long lived deployment that is a real operational consideration, and it is the part of Sofa most likely to need thought about before it goes into production.

Routes, methods and status codes are all adjustable

The route configuration is where a generated API stops being entirely mechanical. Sofa allows you to customise the HTTP method, the path and the response status. The example is the case where a query needs POST instead of the GET that is the default for a Query field:

typescript
api.use(
  '/api',
  useSofa({
    schema,
    routes: {
      'Query.feed': { method: 'POST' },
    },
  })
);

When Sofa defines a route for the feed field of Query, it uses POST instead of GET. The pattern is Type.field, where the type is your query or mutation type.

The same object accepts a path, with dynamic parameters supported, for example /feed/:offset/:limit, and a responseStatus.

The reason this matters is that a GraphQL schema says nothing about HTTP. A mutation named feed could reasonably be a GET that returns a computed feed, or a POST that starts one. Sofa makes a default decision, and the routes option is the escape hatch for the cases where that default is wrong.

The same philosophy shows up in the OpenAPI side of the project. The documentation says that thanks to GraphQL's type system, Sofa is able to generate OpenAPI definitions out of it, and that you get all the information you need to write your own documentation. The openapi-types package is a runtime dependency, which is how the definitions are typed in the generated code.

So the generated API comes with a machine readable description of itself, which is the piece that makes the output usable by other people's tooling rather than only by people who read the schema.

A 0.x library with a 2019 era lineage

The licence is MIT, and the package is maintained by The Guild, with the documentation hosted on the Guild's site under the sofa-api path. The repository is not archived and the last push was on 2026-09-09.

The release history is worth reading closely. v0.18.8 was published on 2024-12-16, v0.18.9 on 2026-02-20 and v0.18.10 on 2026-03-26, and the package metadata shows the current version as 0.18.10. So the line is still 0.x, and a library at 0.x carries the conventional promise that its API may change in a minor release.

The tooling tells you about the project's centre of gravity. Tests run with jest, with a separate test:leaks script that adds --detectOpenHandles and --detectLeaks, which matters for a library that holds HTTP subscriptions open. The build uses bob, with a bob.config.js and a buildOptions input of./src/index.ts. Formatting is prettier, the versions are kept current by renovate, changes are recorded with changesets in a.changeset directory, and the website is deployed with netlify.toml. There is an example directory with the collections, resolvers, types and entry point, and a docs directory.

The honest framing is that this is a mature idea with a young version line, maintained by a well known GraphQL organisation. If you adopt it, pin the version and read the changelog, because a 0.x library with an active maintainer is still a 0.x library.

Editorial conclusion

sofa-api is worth adopting if you already have a GraphQL schema and need a REST surface for partners or clients who will not send a GraphQL query, because the routes, the argument mapping and the OpenAPI definition are all derived from the schema you have rather than written twice. It is the wrong choice if your API is naturally REST, if you need a full CRUD surface with filtering and pagination that GraphQL does not model, or if you cannot accept a 0.x dependency. Before you adopt it, check the peer dependency first, since the package requires graphql 15 or 16, and read the depthLimit and ignore options in the documentation rather than accepting the defaults, because the default of one level is what keeps a nested schema from turning into a recursive payload.

Frequently asked questions

How do I install and use sofa-api?

Install it with yarn add sofa-api or npm install sofa-api, then mount the useSofa middleware with your schema and a basePath. It works with a plain Node http server and with Express, where it is mounted with app.use on a path such as '/api'.

What is a Model in sofa-api?

A Model is a type that is exposed as its own REST resource instead of being inlined into a parent response. To make a type a Model you need two queries, one exposing a list with no non-optional arguments and one fetching a single entity by an id argument, and the type itself must have an id field.

Can sofa-api point at a remote GraphQL server?

Yes. By default it uses the graphql function from graphql-js to execute operations against your local schema, but you can pass an async execute function, and the documentation says that with that you can even use a remote GraphQL server with Fetch or through Apollo Links.

How does sofa-api handle GraphQL subscriptions?

It runs them as webhooks. POST /webhook starts a subscription with the subscription name, optional variables and a url to push events to, and returns a unique id. DELETE /webhook/:id stops it and POST /webhook/:id updates it, returning a new id.

Can I change the HTTP method of a generated route in sofa-api?

Yes. The routes option takes Type.field patterns, so routes: { 'Query.feed': { method: 'POST' } } makes the feed query use POST instead of the default GET for a Query field. The same object accepts a path with dynamic parameters and a responseStatus.

Official sources

  1. graphql-hive/SOFA on GitHub
  2. License: MIT
  3. Project website
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/graphql-hive-sofa.svg)](https://hysenlabs.com/projects/graphql-hive-sofa)

Was this useful?