ConnectRPC API Specification #
Overview #
This document specifies the implementation of a ConnectRPC API for OpenStatus server. ConnectRPC will be used for new features only while the existing REST API remains for current functionality.
Architecture Decisions #
Transport & Protocol #
- Protocol: Connect protocol only (HTTP/1.1 compatible)
- Streaming: Unary calls only (request-response, no streaming)
- Mounting: Same port as REST, mounted at
/rpc/*path prefix on the existing Hono app
Schema Management #
- Approach: Schema-first with
.protofiles - Tooling: Buf (buf.yaml, buf.gen.yaml)
- Location:
packages/proto(shared package for monorepo consumption) - Package naming:
openstatus.<domain>.v1(e.g.,openstatus.monitor.v1)
Code Generation Targets #
- TypeScript (
@bufbuild/protobuf+@connectrpc/connect) - Go (for potential backend service consumers)
Authentication & Authorization #
Supported Methods #
Both authentication methods resolve to the same workspace context:
- API Key (existing system)
- Header:
x-openstatus-key - Formats:
os_[32-char-hex](custom) or Unkey keys - Super admin:
sa_prefix
- Header:
Workspace Context #
- Workspace ID inferred from authenticated credentials
- Super-admin override: Tokens with
sa_prefix can specify target workspace viax-workspace-idmetadata header
Error Handling #
Error Model #
Use ConnectRPC error codes with Google ErrorInfo for structured details:
// Error codes used: NOT_FOUND, INVALID_ARGUMENT, PERMISSION_DENIED,
// UNAUTHENTICATED, RESOURCE_EXHAUSTED, INTERNAL, UNAVAILABLE
// Include ErrorInfo details:
// - domain: "openstatus.com"
// - reason: Machine-readable error reason (e.g., "MONITOR_NOT_FOUND")
// - metadata: Additional context (requestId, resourceId, etc.)
Mapping to Existing Errors #
Reuse OpenStatusApiError codes, map to ConnectRPC equivalents in interceptor.
First Service: Monitor Management #
Service Definition #
syntax = "proto3";
package openstatus.monitor.v1;
import "buf/validate/validate.proto";
import "google/protobuf/timestamp.proto";
// MonitorService provides CRUD and operational commands for monitors.
service MonitorService {
// CreateMonitor creates a new monitor in the workspace.
rpc CreateMonitor(CreateMonitorRequest) returns (CreateMonitorResponse);
// GetMonitor retrieves a single monitor by ID.
rpc GetMonitor(GetMonitorRequest) returns (GetMonitorResponse);
// ListMonitors returns a paginated list of monitors.
rpc ListMonitors(ListMonitorsRequest) returns (ListMonitorsResponse);
// DeleteMonitor removes a monitor.
rpc DeleteMonitor(DeleteMonitorRequest) returns (DeleteMonitorResponse);
// TriggerMonitor initiates an immediate check.
rpc TriggerMonitor(TriggerMonitorRequest) returns (TriggerMonitorResponse);
}
Monitor Type Modeling #
Separate message types for each monitor kind:
// HttpMonitor configuration for HTTP/HTTPS endpoint monitoring.
message HttpMonitor {
// The URL to monitor (required).
string url = 1 [(buf.validate.field).string.uri = true];
// HTTP method to use.
HttpMethod method = 2;
// Request headers to include.
map<string, string> headers = 3;
// Request body for POST/PUT/PATCH.
optional string body = 4;
// Timeout in milliseconds (default: 30000).
int32 timeout_ms = 5 [(buf.validate.field).int32 = {gte: 1000, lte: 60000}];
// Assertions to validate the response.
repeated HttpAssertion assertions = 6;
}
// TcpMonitor configuration for TCP connection monitoring.
message TcpMonitor {
// Host to connect to (required).
string host = 1 [(buf.validate.field).string.min_len = 1];
// Port number (required).
int32 port = 2 [(buf.validate.field).int32 = {gte: 1, lte: 65535}];
// Timeout in milliseconds.
int32 timeout_ms = 3;
}
// DnsMonitor configuration for DNS record monitoring.
message DnsMonitor {
// Domain name to query (required).
string domain = 1 [(buf.validate.field).string.hostname = true];
// DNS record type to check.
DnsRecordType record_type = 2;
// Expected values for the record.
repeated string expected_values = 3;
}
Pagination #
Offset-based pagination for list operations (page_token is the numeric offset):
message ListMonitorsRequest {
// Maximum number of monitors to return (default: 50, max: 100).
int32 page_size = 1 [(buf.validate.field).int32 = {gte: 1, lte: 100}];
// Token from previous response for pagination.
optional string page_token = 2;
// Filter by monitor status.
optional MonitorStatus status_filter = 3;
// Filter by monitor type.
optional MonitorType type_filter = 4;
}
message ListMonitorsResponse {
// The monitors in this page.
repeated Monitor monitors = 1;
// Token for retrieving the next page, empty if no more results.
string next_page_token = 2;
// Total count of monitors matching the filter.
int32 total_count = 3;
}
Validation #
Approach #
Use protovalidate (Buf ecosystem) for request validation:
- Validation rules defined in proto annotations
- Runs before handler via interceptor
- Returns
INVALID_ARGUMENTwith field-level details on failure
Example Annotations #
message CreateMonitorRequest {
string name = 1 [(buf.validate.field).string = {min_len: 1, max_len: 256}];
string description = 2 [(buf.validate.field).string.max_len = 1024];
int32 periodicity = 3 [(buf.validate.field).int32 = {in: [60, 300, 600, 1800, 3600]}];
repeated string regions = 4 [(buf.validate.field).repeated = {min_items: 1, max_items: 35}];
}
Code Organization #
Shared Service Layer #
Both REST and RPC handlers call the same business logic:
packages/proto/ # Shared proto definitions
โโโ buf.yaml # Buf configuration
โโโ buf.gen.yaml # Code generation config
โโโ openstatus/
โ โโโ monitor/
โ โโโ v1/
โ โโโ monitor.proto # Message definitions
โ โโโ service.proto # Service definition
โโโ gen/ # Generated code
โโโ ts/ # TypeScript output
โโโ go/ # Go output
apps/server/src/
โโโ services/ # Shared business logic (NEW)
โ โโโ monitor/
โ โโโ create.ts
โ โโโ get.ts
โ โโโ list.ts
โ โโโ update.ts
โ โโโ delete.ts
โ โโโ operations.ts # trigger, pause, resume
โโโ routes/
โ โโโ v1/ # REST handlers (existing)
โ โโโ monitors/
โโโ rpc/ # ConnectRPC handlers (NEW)
โโโ index.ts # Mount point
โโโ interceptors/
โ โโโ auth.ts # Auth interceptor
โ โโโ logging.ts # Request logging
โ โโโ error.ts # Error mapping
โโโ handlers/
โโโ monitor.ts # MonitorService implementation
Handler Pattern #
// apps/server/src/rpc/handlers/monitor.ts
import type { ConnectRouter } from "@connectrpc/connect";
import { MonitorService } from "@openstatus/proto/gen/ts/openstatus/monitor/v1/service_connect";
import * as monitorService from "../../services/monitor";
export default (router: ConnectRouter) =>
router.service(MonitorService, {
async createMonitor(req, ctx) {
const workspace = ctx.values.get(workspaceKey);
return await monitorService.create(workspace, req);
},
// ... other methods
});
Interceptors #
Authentication Interceptor #
// Extracts and validates auth from headers
// Sets workspace context for downstream handlers
// Supports both API key and Bearer token
Logging Interceptor #
// Integrates with existing LogTape setup
// Logs: method, duration, status, workspace, requestId
Error Interceptor #
// Maps internal errors to ConnectRPC codes
// Attaches ErrorInfo details
// Reports to Sentry (filtered for client errors)
Observability #
Logging #
- Integrate with existing LogTape JSON logging
- Log fields:
rpc.method,rpc.status_code,duration_ms,workspace_id,request_id
Error Tracking #
- Sentry integration via interceptor
- Filter client errors (INVALID_ARGUMENT, NOT_FOUND, etc.)
- Include request context in error reports
Rate Limiting #
Use existing infrastructure:
- Hono middleware / upstream proxy handles rate limiting
- No RPC-specific rate limiting interceptors needed
Testing Strategy #
Unit Tests #
- Test handlers directly with mocked service layer
- Test interceptors in isolation
- Test proto validation rules
Integration Tests #
- Spin up real server instance
- Use generated TypeScript client to make RPC calls
- Test full request lifecycle including auth
Test File Structure #
apps/server/src/rpc/
โโโ __tests__/
โ โโโ handlers/
โ โ โโโ monitor.test.ts # Handler unit tests
โ โโโ interceptors/
โ โ โโโ auth.test.ts # Interceptor tests
โ โโโ integration/
โ โโโ monitor.integration.ts # Full flow tests
Additional Considerations #
Health Check Endpoint #
Add a simple Health service for load balancer probes at /rpc:
service HealthService {
rpc Check(HealthCheckRequest) returns (HealthCheckResponse);
}
message HealthCheckRequest {}
message HealthCheckResponse {
enum ServingStatus {
UNKNOWN = 0;
SERVING = 1;
NOT_SERVING = 2;
}
ServingStatus status = 1;
}
Request ID Propagation #
- Generate
x-request-idin logging interceptor if not present in request headers - Propagate request ID to all downstream services and log entries
- Include request ID in error responses for debugging
Go Code Generation #
- Defer Go codegen until there are concrete Go service consumers
- Reduces maintenance burden and build complexity initially
- Can be enabled later by adding Go target to
buf.gen.yaml
Proto Dependency Pinning #
- Use
buf.lockto pin versions of:buf.build/bufbuild/protovalidatebuf.build/googleapis/googleapis(if using google.protobuf types)
- Run
buf mod updateto generate/update lock file
Configuration Details #
CORS Handling #
/rpcendpoint should inherit existing CORS configuration from Hono app- If different CORS rules needed, configure via Hono middleware before mounting RPC routes
- Connect protocol uses standard HTTP methods (POST), no special CORS requirements
Content-Type Support #
Enable both JSON and binary formats for flexibility:
application/json- Human-readable, easier debugging, slightly larger payloadsapplication/proto- Binary format, smaller payloads, better performance- Connect clients auto-negotiate based on
Content-Typeheader
Deadline/Timeout Propagation #
- Client-specified timeouts via
connect-timeout-msheader - Server interceptor should:
- Read timeout from request metadata
- Create context with deadline
- Cancel operations if deadline exceeded
- Return
DEADLINE_EXCEEDEDerror code on timeout
Dependencies #
New Packages (packages/proto) #
{
"devDependencies": {
"@bufbuild/buf": "latest",
"@bufbuild/protoc-gen-es": "latest",
"@connectrpc/protoc-gen-connect-es": "latest"
},
"dependencies": {
"@bufbuild/protobuf": "^2.0.0",
"@bufbuild/protobuf-conformance": "^2.0.0"
}
}
Server App Additions #
{
"dependencies": {
"@connectrpc/connect": "^2.0.0",
"@connectrpc/connect-node": "^2.0.0",
"@bufbuild/protovalidate": "^0.3.0",
"@openstatus/proto": "workspace:*"
}
}
Migration & Rollout #
Phase 1: Foundation #
- Create
packages/protowith Buf setup - Define monitor service proto
- Generate TypeScript and Go clients
- Add protovalidate annotations
Phase 2: Server Integration #
- Add ConnectRPC dependencies to server
- Implement interceptors (auth, logging, error)
- Mount RPC routes at
/rpcon Hono app - Extract shared service layer from REST handlers
Phase 3: Handler Implementation #
- Implement MonitorService handlers
- Write unit tests
- Write integration tests
- Internal testing
Phase 4: Release #
- Documentation
- Client SDK examples
- Gradual rollout via feature flag (optional)
Open Questions (Resolved) #
| Question | Decision |
|---|---|
| REST replacement or parallel? | New features only |
| Transport protocol | Connect protocol only |
| Streaming | Unary only |
| Schema approach | Schema-first (.proto) |
| Auth mechanism | Both API key + JWT |
| Proto location | Shared package |
| Tooling | Buf |
| Error details | With ErrorInfo |
| Code sharing | Shared service layer |
| Client targets | TypeScript + Go |
| Validation | protovalidate |
| Type modeling | Separate messages |
| Port strategy | Same port, /rpc prefix |
| Pagination | Offset-based |
| Rate limiting | Existing infrastructure |
| Operations style | Separate methods |
| Observability | Sentry + LogTape |
| Testing | Unit + Integration |
| Health check | Yes, HealthService |
| Request ID | Generated + propagated |
| Go codegen | Deferred |
| CORS | Inherit from Hono |
| Content-Type | JSON + Binary |
| Timeouts | connect-timeout-ms header |
References #
Future work #
-
Implement additional services and procedure:
// PauseMonitor suspends monitoring. rpc PauseMonitor(PauseMonitorRequest) returns (PauseMonitorResponse);
// ResumeMonitor resumes a paused monitor. rpc ResumeMonitor(ResumeMonitorRequest) returns (ResumeMonitorResponse);
// UpdateMonitor modifies an existing monitor. rpc UpdateMonitor(UpdateMonitorRequest) returns (UpdateMonitorResponse);