💳 Secure Payment

Full-Service Web & Software Agency · Klamath Falls and Redding

The API: Haskell Servant and Nile

Work with Sean

The last link in the team’s chain is the API behind the app. Module 2’s scenarios already say what each endpoint answers. On the Sean Dinwiddie’s Webmastery team, a Haskell Servant type states it, thin handlers carry it out, and Nile, Postgres built for apps that serve many customers, keeps each customer’s data apart.

1. The API is a type

In Servant, the API is written as a Haskell type before any handler exists. Each route names its path, what it takes and what it returns, and the compiler holds every handler to it:

type OrdersAPI =
  Auth '[JWT] UserId :>
  "tenants" :> Capture "tenantId" TenantId :> "orders" :>
    (    Get '[JSON] [Order]
    :<|> ReqBody '[JSON] NewOrder :> PostCreated '[JSON] Order )

The type reads like the scenario it serves: for a signed-in user and one customer, list the orders, or place one and answer 201 Created with the order. The Auth combinator, from servant-auth, puts the caller in the type, so no handler can forget to ask who is calling. A handler that returns the wrong shape doesn’t compile, so a route and its code never drift apart.

2. Handlers stay thin

A handler parses the request, calls pure functions that decide, and performs the effects the route needs inside one transaction:

  1. it reads what a decision needs, such as today’s prices;
  2. it lets the pure code decide;
  3. it writes the result.

The client’s total is for display; the API prices the order from its own catalog. The decisions, such as whether an order is valid and what it totals, live in plain functions with no database in sight, the same boundary Redux Toolkit keeps between reducers and RTK Query. The names below are the lesson’s own:

server :: Pool Connection -> Server OrdersAPI
server pool (Authenticated user) tenantId = listOrders :<|> placeOrder
  where
    listOrders = withTenant pool user tenantId selectOrders
    placeOrder new = do
      decided <- withTenant pool user tenantId $ \conn -> do
        prices <- selectPrices conn new
        traverse (insertOrder conn) (validateOrder prices new)
      either (\problem -> throwError err422 { errBody = encode problem, errHeaders = [(hContentType, "application/json")] }) pure decided
server _ _ _ = throwAll err401

-- One transaction per request: name the tenant and the user, run the work, commit.
-- Nile refuses a user outside the tenant, and the refusal answers 403.
withTenant :: Pool Connection -> UserId -> TenantId -> (Connection -> IO a) -> Handler a
withTenant pool user tenantId work = do
  result <- liftIO . try . withResource pool $ \conn ->
    withTransaction conn (setTenantAndUser conn tenantId user *> work conn)
  case result of
    Left err | notAMember err -> throwError err403
             | otherwise      -> liftIO (throwIO err)
    Right a -> pure a

servant-auth doesn’t refuse a request itself: it hands the server an AuthResult, and the last clause answers every route with 401 when the sign-in isn’t valid, while withTenant answers 403 when Nile refuses the user.

One POST through the API A request to place an order runs down a highlighted line: Auth checks the sign-in, withTenant names the tenant and the user, selectPrices reads today's prices, validateOrder decides, insertOrder writes, and the API answers 201 Created. The steps from withTenant to insertOrder run in one transaction. Three exits branch to the right: a sign-in that isn't valid answers 401, a user who isn't a member of the tenant answers 403, and an invalid order answers 422. Auth: signed in?withTenantselectPricesvalidateOrderinsertOrder201 Creatednot valid401not a member403invalid422
The violet line is an order that is placed: a valid sign-in, a member of the tenant, an order that passes the pure check and is written, all from withTenant to insertOrder in one transaction, then 201 Created. Each refusal leaves at its own step, with its own status.

The lecture Functional Programming in Other Languages draws the same line as a functional core and an imperative shell, and What Is a Function? shows why the core stays pure.

3. Each customer’s data stays apart in Nile

Nile is Postgres built for apps that serve many organizations, each a tenant. A table that holds a tenant’s rows carries a tenant_id and includes it in its primary key, and the tenants themselves live in Nile’s built-in tenants table:

CREATE TABLE orders (
  tenant_id uuid REFERENCES tenants (id),
  id uuid DEFAULT gen_random_uuid(),
  total_cents integer NOT NULL,
  PRIMARY KEY (tenant_id, id)
);

CREATE TABLE order_lines (
  tenant_id uuid,
  order_id uuid,
  product_id text,
  name text NOT NULL,
  price_cents integer NOT NULL,
  quantity integer NOT NULL CHECK (quantity > 0),
  PRIMARY KEY (tenant_id, order_id, product_id),
  FOREIGN KEY (tenant_id, order_id) REFERENCES orders (tenant_id, id)
);

On the wire, a NewOrder carries only each line’s product and quantity, and the Order the API returns adds each line’s name and price and the total, in cents: the shapes Endpoints at the boundary decodes.

Inside withTenant, the handler opens a transaction and names the tenant and the user before its queries:

SET LOCAL nile.tenant_id = '018ade1a-7830-7981-b23f-f3a7f7b8f09f';
SET LOCAL nile.user_id = '0190a6c4-2f1e-7b3d-9a5c-6e8d4f2b1a07';
SELECT id, total_cents FROM orders;

From there the connection sees only that tenant’s rows, as if it pointed at the tenant’s own database, while shared tables, such as a product catalog, stay readable. SET LOCAL ends with the transaction, so a pooled connection never carries one customer into the next request. Nile speaks the Postgres protocol, so the Haskell side uses an ordinary Postgres library.

What a tenant's connection sees Two tables. In orders, a tenant table, four rows belong to two shops; with the tenant set to the shop, the connection sees only the shop's two rows, outlined and highlighted, and the other shop's rows are hidden. In products, a shared table, both rows stay readable and are highlighted too. nile.tenant_id = the shoporders: tenant tableproducts: sharedshop · order 1other shop: hiddenshop · order 2other shop: hiddenWidget AWidget B
With nile.tenant_id set to the shop, a tenant table such as orders shows the connection only the shop’s rows, outlined in violet. A shared table, such as the product catalog, stays readable.

The tenant in the path is only a claim. With nile.user_id set, Nile raises an error unless that user belongs to the tenant in its built-in users.tenant_users table, and the shell answers 403 Forbidden; an app that keeps its own users checks membership the same way before withTenant runs.

SET takes no bind parameters, so both IDs are written into the statement, which is safe here because the Capture and the auth layer have already parsed them as UUIDs.

4. One contract at both ends

The same Servant type can describe itself as an OpenAPI document (the servant-openapi3 package), and RTK Query’s OpenAPI code generator turns that document into the client’s endpoints and hooks. Server and client then share one contract: when a route changes, the regenerated client shows every mismatch as a type error before it reaches a customer.

The lecture Redux Toolkit and RTK Query Best Practices sets up the client side, generated into one API root and reviewed like any other source.

servant-openapi3 knows nothing of servant-auth, so the API adds one small instance that describes Auth as a bearer token, and names each route’s operation, which RTK Query’s generator uses as the endpoint’s name. A small program writes the document to openapi.json on every build.

{-# OPTIONS_GHC -Wno-orphans #-}

-- openapi3's own map type, the one its lenses use
import qualified Data.HashMap.Strict.InsOrd.Compat as InsOrd

-- Every route under Auth takes a bearer token.
instance HasOpenApi api => HasOpenApi (Auth '[JWT] a :> api) where
  toOpenApi _ =
    toOpenApi (Proxy :: Proxy api)
      & components . securitySchemes <>~ SecurityDefinitions (InsOrd.fromList [("jwt", jwt)])
      & allOperations . security <>~ [SecurityRequirement (InsOrd.fromList [("jwt", [])])]
    where
      jwt = SecurityScheme (SecuritySchemeHttp (HttpSchemeBearer (Just "JWT"))) Nothing

ordersOpenApi :: OpenApi
ordersOpenApi =
  toOpenApi (Proxy :: Proxy OrdersAPI)
    & paths . traverse . get . _Just . operationId ?~ "listOrders"
    & paths . traverse . post . _Just . operationId ?~ "placeOrder"

With one path, a traversal over every path is enough; a larger API picks each route out with servant-openapi3’s subOperations.

The contract is only as honest as the JSON behind it: in the Hspec suite, servant-openapi3’s validateEveryToJSON checks values of every JSON type in the API against the published schema, generating them from each type’s Arbitrary instance, so Order and NewOrder need one.

5. The scenarios test the API

Module 2’s scenarios run against the API as well as the slice. Hspec, with hspec-wai, calls the Servant application directly, with no browser and no HTTP server, against Nile running locally in Docker so the tenant checks run too.

Every route sits behind Auth, so each test signs a token with the test’s own key and sends it as a bearer token: Ada, a member of the shop’s tenant, gets 201 Created, and Grace, a user from another shop, gets 403 Forbidden.

app :: JWTSettings -> Pool Connection -> Application
app jwt pool =
  serveWithContext (Proxy :: Proxy OrdersAPI) (defaultCookieSettings :. jwt :. EmptyContext) (server pool)

spec :: Spec
spec = do
  jwt <- runIO (defaultJWTSettings <$> generateKey)
  pool <- runIO localNile   -- Nile in Docker, with Ada a member of the shop's tenant
  let signIn user = either (error . show) toStrict <$> makeJWT user jwt Nothing
      asUser token = [(hAuthorization, "Bearer " <> token), (hContentType, "application/json")]
  with (pure (app jwt pool)) $
    describe "Feature: Checkout" $ do
      it "Scenario: Placing the order" $ do
        token <- liftIO (signIn ada)
        request methodPost (ordersPath shop) (asUser token) widgetAOrder `shouldRespondWith` 201
      it "Scenario: Placing an order in someone else's shop" $ do
        token <- liftIO (signIn grace)
        request methodPost (ordersPath shop) (asUser token) widgetAOrder `shouldRespondWith` 403

Each test carries its scenario’s name, such as it "Scenario: Placing the order", so a failing test names the behavior that broke. The pure core, such as validateOrder, is tested on its own, with no database at all. The lecture Functional Programming in Other Languages makes the same point across languages: the test names and fixtures can be shared even where the implementations cannot.

The API closes the chain the course began with: a feature scoped as a user story, its scenarios in Gherkin, the slice and endpoint that pass them, and the typed API and tenant-safe data behind them. The next lesson follows one of those scenarios back into the client, as a Redux Toolkit slice.

Copyright Sean Paul Payne Dinwiddie
All Rights Reserved