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 .proto service 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 Authorization headers, 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:

  1. Detects an UNAUTHENTICATED error response
  2. Uses the refresh token to obtain a new JWT
  3. 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

CodeMeaningExample
NOT_FOUNDResource doesn’t existDrill ID not in this org
PERMISSION_DENIEDAuthenticated but not authorizedMember trying to edit an event they don’t own
UNAUTHENTICATEDMissing or invalid tokenExpired JWT
INVALID_ARGUMENTRequest failed validationScore missing required fields
ALREADY_EXISTSDuplicate detectedDedup key collision on score
FAILED_PRECONDITIONState doesn’t allow the operationSubmitting scores to a completed event
INTERNALServer errorUnexpected 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

FlowRPC Type
RO submits a scoreUnary
Dashboard watches live scoresServer streaming
Device pulls event config before startUnary
Dashboard watches roster changesServer streaming
Flush offline score queueRepeated 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

PlatformLibraryGenerated 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.