Skip to content

joshburgess/acolyte

Repository files navigation

acolyte

A composable, type-safe web framework for Haskell. Your API is a type, your middleware is tracked at compile time, and your backend is pluggable.

Built on GHC 9.10.3. Informed by Rust's tower/axum/typeway ecosystem.

Prerequisites

ghcup install ghc 9.10.3
ghcup set ghc 9.10.3

Quick start

Clone and build:

git clone <repo-url> acolyte
cd acolyte
cabal build all        # ~7s clean build
cabal test all         # 27 test suites, 1000+ assertions, 60+ hedgehog properties

Run the hello-world example:

cabal run hello-world

In another terminal:

curl http://localhost:3000/health        # -> ok
curl http://localhost:3000/users         # -> ["alice","bob","charlie"]
curl http://localhost:3000/users/42      # -> "user-42"

Your first API in 4 steps

1. Define the API as a type

Endpoints are a promoted list. Paths use type-level strings. Captures are typed.

{-# LANGUAGE DataKinds #-}

import Acolyte.Core
import Acolyte.Server

type HealthPath = At "health"           -- expands to '[ 'Lit "health" ]
type UsersPath  = At "users"            -- expands to '[ 'Lit "users" ]

type API =
  '[ Get HealthPath   Text              -- GET /health -> Text
   , Get UsersPath    (Json [Text])     -- GET /users  -> JSON array
   ]

2. Write handlers

Handlers are plain IO functions. The type signature is the extraction: arguments are automatically pulled from the request.

healthHandler :: IO Text
healthHandler = pure "ok"

listUsersHandler :: IO (Json [Text])
listUsersHandler = pure (Json ["alice", "bob"])

-- Path captures are typed and extracted automatically:
getUser :: PathCapture Int -> IO (Json Text)
getUser (PathCapture uid) = pure (Json (T.pack ("user-" ++ show uid)))

No monad transformers, no liftIO, no manual extraction boilerplate.

3. Wire handlers to the API

mkApi checks at compile time that you have the right number of handlers and that each one matches its endpoint type. No wrapHandler, no toHandler. Just pass your functions positionally.

import Spire (Service, (|>))
import Spire.Http (secureHeadersLayer, defaultSecureHeaders)

server :: Service IO (Request ByteString) (Response ByteString)
server = mkApi @API (healthHandler, listUsersHandler)

For larger APIs, wrap endpoints with Named and use mkRecordApi to match handlers by field name instead of position:

type NamedAPI =
  '[ Named "health"    (Get HealthPath   Text)
   , Named "listUsers" (Get UsersPath    (Json [Text]))
   ]

data Handlers = Handlers
  { listUsers :: IO (Json [Text])  -- order doesn't matter
  , health    :: IO Text
  }

server = mkRecordApi @NamedAPI Handlers { ... }

4. Add middleware and run

Middleware composes with |>. Pick a backend: spire-server (zero WAI) or spire-wai (warp).

import Spire.Server (runServerBS)

main :: IO ()
main = do
  let app = server |> secureHeadersLayer defaultSecureHeaders
  runServerBS 3000 app

That's it. You have a running server with OWASP security headers, compile-time route checking, and no WAI dependency.

Typed middleware effects

Endpoints can declare what middleware they require. The compiler enforces that you provide it.

type EffectAPI =
  '[ Requires Auth (Get UserPath (Json User))  -- needs auth
   , Get HealthPath Text                       -- no requirements
   ]

app = run
    $ provide @Auth authMiddleware
    $ effectfulServer @EffectAPI (authHandler, healthHandler)

Forget provide @Auth? You get a compile error:

Missing middleware effect: Auth
This effect is required by an endpoint but was not provided.

Testing without a network

acolyte-test dispatches requests directly through the spire Service. No ports, no sockets, deterministic.

import Acolyte.Test

test :: IO ()
test = do
  let svc = mkServer @API (health, users)
  resp <- get svc "/health"
  resp `shouldHaveStatus` 200
  resp `shouldHaveBody` "ok"

WebSocket session types

WebSocket protocols are enforced at compile time via phantom-typed session handles. Each operation (send, recv, offer, select) transitions the type-level state, so the compiler rejects out-of-order messages.

import Spire.WebSocket
import Acolyte.Core.Session (SessionType (..))

type EchoProtocol = 'Send Text ('Recv Text 'End)

echoHandler :: Session EchoProtocol -> IO ()
echoHandler session = do
  session'         <- send ("hello" :: Text) session
  (msg, session'') <- recv session'
  close session''

The protocol type drives correctness: calling recv when the protocol expects send is a type error. Branching protocols use Offer / Select, and recursive protocols use Rec / Var with recurse / loop.

gRPC from the same API type

The same API type drives REST and gRPC:

-- REST server:
restSvc = mkServer @API restHandlers

-- gRPC server (same API type!):
grpcSvc = grpcServer (mkGrpcServiceMap @API "pkg" "Svc" grpcHandlers)

-- Multiplex REST + gRPC on a single port:
combined = multiplex (adaptToBody restSvc) grpcSvc

-- .proto file with full message definitions:
proto = generateProto @API "pkg" "Svc"

-- Or go the other way (.proto to API types):
-- cabal run proto-codegen -- service.proto

Run on HTTP/2 via spire-server (zero WAI):

main = runServerH2 (defaultH2Config 50051) grpcSvc

Features: REST+gRPC multiplexing (Spire.Grpc.Multiplex), content negotiation (Acolyte.Server.Negotiate), server reflection (Spire.Grpc.Reflection), bidirectional .proto codegen, client streaming and bidirectional streaming handlers, gRPC health check service (Spire.Grpc.Health), and gzip compression (Spire.Grpc.Compression).

See the gRPC guide for the full walkthrough.

Architecture

   ┌────────────────────────────────────────┐
   │  Top-level frameworks                  │
   │    acolyte-server   REST + spire       │
   │    acolyte-grpc     gRPC + spire       │
   └─────────────────────┬──────────────────┘
                         │ produces a Service
                         ▼
   ┌────────────────────────────────────────┐
   │  Service layer                         │
   │    spire / spire-http / spire-grpc     │
   │    Service / Layer / framing           │
   └─────────────────────┬──────────────────┘
                         │
                         ▼
   ┌────────────────────────────────────────┐
   │  Backend-agnostic core                 │
   │    http-core                           │
   │    Request / Response abstraction      │
   └─────────────────────┬──────────────────┘
                         │
                         ▼
   ┌────────────────────────────────────────┐
   │  Backend (pick one)                    │
   │    spire-wai      via warp             │
   │    spire-server   via raw sockets      │
   │    HTTP/1.1, HTTP/2                    │
   └────────────────────────────────────────┘

   spire-websocket: peer to spire-http / spire-grpc, adds linear session types

Each layer is independent. spire knows nothing about HTTP. http-core knows nothing about WAI. spire-grpc knows nothing about API types. The server produces a Service and doesn't know or care what runs it.

Packages

Package What it does
acolyte-core Type-level API: endpoints, paths, effects, sessions, versioning. Depends on base only.
spire Service/Layer/Middleware composition. Depends on base only. Standalone (use it anywhere).
http-core Backend-agnostic Request, Response, Extensions (typed heterogeneous map).
spire-http HTTP middleware: security headers, request ID, tracing, CORS, gzip, timeouts.
spire-wai WAI/warp backend adapter. The only package that imports WAI.
spire-server Spire-native HTTP/1.1 + HTTP/2 server with TLS. Zero WAI dependency.
spire-grpc gRPC wire protocol: framing, status codes, service dispatch. No protobuf dependency.
acolyte-server Handler wiring, routing, extractors, effect tracking.
acolyte-client Type-safe HTTP client derived from the same API type.
acolyte-openapi OpenAPI 3.1 + Swagger 2.0 spec generation from API types. Annotations (Describe, WithParams, WithHeaders, RespondsWith) populate real operation summaries, parameter schemas, status codes, and request/response body schemas. Custom types get schemas automatically via Generic-based ToSchema derivation.
acolyte-codegen Generate API types from OpenAPI/Swagger specs.
acolyte-grpc gRPC interpretation: GrpcCodec, GrpcReady, .proto generation, mkGrpcServiceMap.
acolyte-test Direct-dispatch testing: no network, no ports.
spire-websocket WebSocket session types: phantom-typed Session handle enforces send/recv protocol at compile time.
acolyte Facade. Re-exports everything for convenience.

Examples

The examples/ directory contains 12 complete applications:

Key design decisions

  • Promoted lists, not trees. APIs are '[endpoint1, endpoint2, ...]. Instance resolution is one flat tuple match per arity instead of a recursive walk over Servant's :<|> tree. See docs/PERFORMANCE.md for a head-to-head compile-time bench.
  • IO handlers. No ExceptT, no ReaderT. State via extractors (AppState), errors via Either.
  • spire Service as the boundary. The server produces it, the backend consumes it. Middleware composes with |>.
  • WAI is confined. Only spire-wai imports WAI. Swap it for spire-server, a Lambda adapter, or anything else.
  • Compile times are first-class. 1 to 32 endpoints compile in roughly the same wall-clock time (~1.63s on an Apple Silicon laptop, dominated by GHC startup and dependency loading). The in-repo bench also runs the same API under Servant 0.20.3.0 for direct comparison.
  • Runtime is fast. 142 ns dispatch, 14 ns gRPC decode, middleware adds zero overhead. See the Performance Guide for full benchmarks and analysis.
  • Optional type-level annotations. Endpoints can be wrapped with Describe, WithParams, WithHeaders, RespondsWith / PostCreated / DeleteNoContent, Versioned, and streaming markers (ServerStream, ClientStream, BidiStream) for richer OpenAPI specs and client generation, without changing routing or handler signatures. Versioned V1 (Get ...) routes to /v1/... and is supported by the server, client, and OpenAPI interpretations. See the tutorial for details.
  • Named endpoints. Wrap endpoints with Named "fieldName" to enable record-based handler binding via mkRecordApi. Field order doesn't matter, the compiler matches by name. Named also sets operationId in OpenAPI specs. Fully opt-in: unnamed endpoints and positional tuples continue to work unchanged. On the client side, mkClientRecord constructs a typed client record from Named APIs via Generic.
  • Request validation. ValidatedBody v a deserializes JSON and runs a Validate v a check before the handler sees the data. Validation failures return 422 with a structured error. Define a validator type, write a Validate instance, and use ValidatedBody in the handler signature: no manual error-checking boilerplate.
  • Async SSE streaming. sseResponse runs the handler in a forked thread and delivers Server-Sent Events incrementally via chunked transfer encoding. For simple cases, sseResponseSync collects all events first.

Next steps

  • Read the Tutorial: a step-by-step walkthrough building a complete API
  • Read the Design Philosophy: why every decision was made, compile-time performance analysis, Servant comparison
  • Read the Data Flow: request-to-response flow diagrams for HTTP/1.1, HTTP/2, gRPC, and the compile-time type checking flow
  • Coming from Servant? Read the Migration Guide for a side-by-side comparison of every concept
  • Read the Error Handling Guide for patterns on structured errors, early return, and fallible extractors
  • Read the Streaming Guide for large request/response bodies
  • Read the gRPC Guide for serving gRPC from the same API type
  • Read the Performance Guide for compile-time and runtime benchmarks with analysis
  • Browse the examples for patterns to copy
  • See ARCHITECTURE.md for the full design

License

BSD 3-Clause. See LICENSE.

About

A reimagining of the ideas pioneered by Haskell's servant and Rust's typeway, axum, tower, & tonic. A composable, type-safe web framework for Haskell. Your API is a type, your middleware is tracked at compile time, and your backend is pluggable.

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors