API Design
Strategy
All client-server communication uses ConnectRPC — a modern RPC framework that generates type-safe clients from protobuf service definitions. ConnectRPC serves three wire formats from the same endpoints:
- JSON — for debugging, curl, browser dev tools
- Protobuf binary — for efficient mobile sync
- gRPC — for interop with gRPC tooling
The client chooses the format via Content-Type. The server handles all three transparently.
Why ConnectRPC
- Generated clients — typed Swift, Kotlin, and web clients from
.protoservice definitions. No hand-written API code on any platform. - JSON + protobuf from the same endpoint — debug with curl and JSON, ship with protobuf binary for efficiency.
- Works in browsers — HTTP/1.1 compatible, no gRPC-Web proxy needed. Dashboard and PWA connect directly.
- Standard HTTP — normal
Authorizationheaders, standard status codes, works with any HTTP infrastructure. - Server streaming — server pushes live updates (scores, roster changes) to connected clients without WebSockets.
- Proto definitions already exist — the
proto/directory has message definitions. Service definitions are added alongside them.
Service Definitions
Services are defined in .proto files alongside existing message definitions:
// proto/score/v1/score_service.proto
syntax = "proto3";
package score.v1;
service ScoreService {
// Submit a score entry (append-only)
rpc CreateScore(CreateScoreRequest) returns (CreateScoreResponse);
// Get final scores for an event (excludes voided/replaced)
rpc ListScores(ListScoresRequest) returns (ListScoresResponse);
// Live score feed — server pushes new scores as they arrive
rpc WatchScores(WatchScoresRequest) returns (stream Score);
}Package Versioning
Versioning is built into the proto package path: score.v1, event.v1, etc. A breaking change produces score.v2 — old clients continue using v1 until migrated. No URL-based /api/v1/ prefix needed.
URL Structure
ConnectRPC derives endpoint URLs from the package and service name:
POST /score.v1.ScoreService/CreateScore
POST /score.v1.ScoreService/ListScores
POST /event.v1.EventService/GetEvent
All methods use POST (RPC-style, not REST verbs). The URL is deterministic from the proto definition.
Authentication
Standard Bearer token in the Authorization header:
Authorization: Bearer <jwt>
The server’s auth middleware validates the JWT and produces an AuthContext (see Auth Architecture). ConnectRPC interceptors on the client side attach the header to every request automatically.
Token Refresh
Generated clients can be configured with an interceptor that:
- Detects an
UNAUTHENTICATEDerror response - Uses the refresh token to obtain a new JWT
- Retries the original request
This is client-side logic in the interceptor, not a ConnectRPC-specific feature.
Error Handling
ConnectRPC has built-in error codes modeled after gRPC status codes. Errors are returned in whatever format the client requested (JSON or protobuf).
Standard Error Codes
| Code | Meaning | Example |
|---|---|---|
NOT_FOUND | Resource doesn’t exist | Drill ID not in this org |
PERMISSION_DENIED | Authenticated but not authorized | Member trying to edit an event they don’t own |
UNAUTHENTICATED | Missing or invalid token | Expired JWT |
INVALID_ARGUMENT | Request failed validation | Score missing required fields |
ALREADY_EXISTS | Duplicate detected | Dedup key collision on score |
FAILED_PRECONDITION | State doesn’t allow the operation | Submitting scores to a completed event |
INTERNAL | Server error | Unexpected failure |
Error Details
Errors can carry structured detail messages for machine-readable context:
{
"code": "invalid_argument",
"message": "Score validation failed",
"details": [
{
"type": "score.v1.ValidationError",
"value": {"field": "time", "reason": "must be positive"}
}
]
}The same error in protobuf binary is compact on the wire but carries identical information.
Server Streaming
Server streaming provides live updates without WebSockets. The client opens a stream and receives messages as they arrive:
// Client subscribes to live scores for an event
rpc WatchScores(WatchScoresRequest) returns (stream Score);
// Client subscribes to roster changes
rpc WatchRoster(WatchRosterRequest) returns (stream RosterUpdate);Use Cases
| Flow | RPC Type |
|---|---|
| RO submits a score | Unary |
| Dashboard watches live scores | Server streaming |
| Device pulls event config before start | Unary |
| Dashboard watches roster changes | Server streaming |
| Flush offline score queue | Repeated unary |
Bidirectional streaming (client and server both streaming) is not supported over HTTP/1.1. This is not a limitation — all RDP data flows are unary or server-streaming.
Platform Clients
| Platform | Library | Generated From |
|---|---|---|
| iOS (Swift) | connect-swift | .proto service definitions |
| Android (Kotlin) | connect-kotlin | .proto service definitions |
| Dashboard / PWA (WASM) | connect-web or tonic | .proto service definitions |
| Server (Rust) | tonic | .proto service definitions |
All clients are generated — no hand-written HTTP calls. A change to a proto service definition surfaces as compile errors in every consumer.
Interceptors
Generated clients support interceptors for cross-cutting concerns:
- Auth — attach
Authorization: Bearer <jwt>to every request - Token refresh — catch
UNAUTHENTICATED, refresh, retry - Logging — log request/response for debugging
- Retry — retry transient failures with backoff
Offline Score Queue
The offline queue (see Sync Model) flushes by calling CreateScore repeatedly — one unary RPC per queued score. This is simpler than batching and allows per-score error handling (e.g., dedup collision on one score doesn’t block the rest).
If batch submission becomes necessary at scale, a BatchCreateScores RPC can be added without changing the queue logic.
CLI Integration
rangeday proto generate # buf generate — produces Rust server code + client stubs
rangeday bindings swift # UniFFI bindings (core crate, separate from proto clients)
rangeday bindings kotlin # UniFFI bindings (core crate, separate from proto clients)
Proto generation produces ConnectRPC service stubs. UniFFI bindings are separate — they expose the core crate’s business logic, not the API layer.