// -*- coding: utf-8 -*-
// rest_handlers.go - REST API handlers for slow-time-server
//
// Copyright 2025
// SPDX-License-Identifier: Apache-2.0
//
// This file implements REST API endpoints that complement the MCP protocol,
// providing direct HTTP access to time operations with configurable latency.
package main
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
)
// SlowTimeResponse represents the response for slow time operations.
type SlowTimeResponse struct {
Time string `json:"time"`
Timezone string `json:"timezone"`
Unix int64 `json:"unix"`
UTC string `json:"utc"`
DelayedBy string `json:"delayed_by"`
ActualDelay string `json:"actual_delay"`
}
// ErrorResponse represents an API error response.
type ErrorResponse struct {
Error string `json:"error"`
Message string `json:"message"`
Code int `json:"code"`
}
func writeJSONError(w http.ResponseWriter, code int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(ErrorResponse{
Error: http.StatusText(code),
Message: message,
Code: code,
})
}
func writeJSON(w http.ResponseWriter, code int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
if err := json.NewEncoder(w).Encode(data); err != nil {
logAt(logError, "Failed to encode JSON response: %v", err)
}
}
// handleRESTGetTime handles GET /api/v1/time?timezone=X&delay=Y
func handleRESTGetTime(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
timezone := r.URL.Query().Get("timezone")
if timezone == "" {
timezone = "UTC"
}
loc, err := time.LoadLocation(timezone)
if err != nil {
writeJSONError(w, http.StatusBadRequest, fmt.Sprintf("Invalid timezone: %s", timezone))
return
}
// Parse delay from query or use server default
delay := lcfg.computeDelay()
if delayStr := r.URL.Query().Get("delay"); delayStr != "" {
if d, err := strconv.ParseFloat(delayStr, 64); err == nil {
delay = time.Duration(d * float64(time.Second))
}
}
start := time.Now()
if err := contextSleep(r.Context(), delay); err != nil {
stats.record(time.Since(start), true)
writeJSONError(w, http.StatusGatewayTimeout, fmt.Sprintf("Request cancelled after %s", time.Since(start).Round(time.Millisecond)))
return
}
elapsed := time.Since(start)
stats.record(elapsed, false)
now := time.Now().In(loc)
response := SlowTimeResponse{
Time: now.Format(time.RFC3339),
Timezone: timezone,
Unix: now.Unix(),
UTC: now.UTC().Format(time.RFC3339),
DelayedBy: delay.String(),
ActualDelay: elapsed.Round(time.Millisecond).String(),
}
writeJSON(w, http.StatusOK, response)
}
// handleRESTGetConfig handles GET /api/v1/config
func handleRESTGetConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
writeJSON(w, http.StatusOK, lcfg.snapshot())
}
// handleRESTUpdateConfig handles POST /api/v1/config for runtime reconfiguration.
func handleRESTUpdateConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
var body struct {
DefaultLatency string `json:"default_latency"`
Distribution string `json:"distribution"`
FailureRate float64 `json:"failure_rate"`
FailureMode string `json:"failure_mode"`
MinLatency string `json:"min_latency"`
MaxLatency string `json:"max_latency"`
MeanLatency string `json:"mean_latency"`
StddevLatency string `json:"stddev_latency"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSONError(w, http.StatusBadRequest, "Invalid request body")
return
}
lcfg.mu.Lock()
defer lcfg.mu.Unlock()
if body.DefaultLatency != "" {
if d, err := time.ParseDuration(body.DefaultLatency); err == nil {
lcfg.defaultLatency = d
}
}
if body.Distribution != "" {
lcfg.distribution = body.Distribution
}
if body.FailureRate >= 0 && body.FailureRate <= 1 {
lcfg.failureRate = body.FailureRate
}
if body.FailureMode != "" {
lcfg.failureMode = body.FailureMode
}
if body.MinLatency != "" {
if d, err := time.ParseDuration(body.MinLatency); err == nil {
lcfg.minLatency = d
}
}
if body.MaxLatency != "" {
if d, err := time.ParseDuration(body.MaxLatency); err == nil {
lcfg.maxLatency = d
}
}
if body.MeanLatency != "" {
if d, err := time.ParseDuration(body.MeanLatency); err == nil {
lcfg.meanLatency = d
}
}
if body.StddevLatency != "" {
if d, err := time.ParseDuration(body.StddevLatency); err == nil {
lcfg.stddevDelay = d
}
}
logAt(logInfo, "config updated: default_latency=%s distribution=%s failure_rate=%.2f failure_mode=%s",
lcfg.defaultLatency, lcfg.distribution, lcfg.failureRate, lcfg.failureMode)
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "updated",
"config": lcfg.snapshotLocked(),
})
}
// handleRESTGetStats handles GET /api/v1/stats
func handleRESTGetStats(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
writeJSON(w, http.StatusOK, stats.snapshot())
}
// handleRESTTestEcho handles GET /api/v1/test/echo
func handleRESTTestEcho(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
message := r.URL.Query().Get("message")
if message == "" {
message = "Hello from slow-time-server!"
}
writeJSON(w, http.StatusOK, map[string]string{
"echo": message,
"timestamp": time.Now().Format(time.RFC3339),
"server": appName,
})
}
// handleOpenAPISpec handles GET /api/v1/openapi.json
func handleOpenAPISpec(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
spec := getOpenAPISpec()
writeJSON(w, http.StatusOK, spec)
}
// handleAPIDocs handles GET /api/v1/docs
func handleAPIDocs(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
return
}
html := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Slow Time Server API Documentation</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui.css">
</head>
<body>
<div id="swagger-ui"></div>
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
<script>
window.onload = function() {
SwaggerUIBundle({
url: "/api/v1/openapi.json",
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,
SwaggerUIBundle.SwaggerUIStandalonePreset
],
layout: "BaseLayout"
});
}
</script>
</body>
</html>`
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(html))
}
// handleRESTConfigRoute dispatches GET/POST to the correct handler.
func handleRESTConfigRoute(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
handleRESTGetConfig(w, r)
case http.MethodPost:
handleRESTUpdateConfig(w, r)
default:
writeJSONError(w, http.StatusMethodNotAllowed, "Method not allowed")
}
}
// registerRESTHandlers registers all REST API handlers.
func registerRESTHandlers(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/time", handleRESTGetTime)
mux.HandleFunc("/api/v1/config", handleRESTConfigRoute)
mux.HandleFunc("/api/v1/stats", handleRESTGetStats)
mux.HandleFunc("/api/v1/test/echo", handleRESTTestEcho)
mux.HandleFunc("/api/v1/openapi.json", handleOpenAPISpec)
mux.HandleFunc("/api/v1/docs", handleAPIDocs)
}
// corsMiddleware adds CORS headers to responses.
func corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
w.Header().Set("Access-Control-Max-Age", "3600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}