feat: expose runtime OpenAPI contract

Register typed API operations and serve an always-current OpenAPI document without a generation command or committed generated artifact.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-01 18:54:02 +02:00
parent 314c783659
commit 045f0b7cf2
9 changed files with 533 additions and 76 deletions

View file

@ -95,5 +95,8 @@ exposes only candidate-safe schedule and shared-document data.
unexpected failures instead of silently falling back. unexpected failures instead of silently falling back.
- Update the directly related local documentation whenever behavior, - Update the directly related local documentation whenever behavior,
configuration, architecture, commands, or public API surfaces change. configuration, architecture, commands, or public API surfaces change.
- Register backend routes through the typed OpenAPI-aware router helper. The
runtime `/api/openapi.json` document is the API source of truth; do not add a
manually maintained route inventory or require a generation command.
- Commit every repository change with a meaningful conventional prefix such as - Commit every repository change with a meaningful conventional prefix such as
`feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`. `feat:`, `fix:`, `refactor:`, `test:`, `docs:`, or `chore:`.

View file

@ -54,6 +54,19 @@ hostnames; the API CORS policy permits `*.guess.local` development origins and
the `X-Tenant-Id` request header. Non-local deployments keep using same-origin the `X-Tenant-Id` request header. Non-local deployments keep using same-origin
`/api`. Set a different secure `JWT_SECRET` outside local development. `/api`. Set a different secure `JWT_SECRET` outside local development.
## API contract
The backend exposes its current OpenAPI 3.0 contract at
<http://localhost:8080/api/openapi.json>. The document is assembled in memory
from the same typed registrations that add routes to Chi, including request and
response schemas, status codes, authentication, tenant headers, path/query
parameters, multipart uploads, downloads, and event streams. It therefore
requires no generation command and cannot miss a newly registered route.
`/api/openapi.json` and `/api/health` are system endpoints and do not require a
tenant header. All tenant data endpoints declare `X-Tenant-Id`; protected
operations additionally declare JWT bearer authentication.
## Quality commands ## Quality commands
```bash ```bash

View file

@ -20,6 +20,10 @@ var safeSchemaRegex = regexp.MustCompile(`^[a-zA-Z0-9_:-]+$`)
func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler { func TenantSchemaMiddleware(db *gorm.DB) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/api/health" || r.URL.Path == "/api/openapi.json" {
next.ServeHTTP(w, r)
return
}
tenantID := r.Header.Get("X-Tenant-Id") tenantID := r.Header.Get("X-Tenant-Id")
if tenantID == "" { if tenantID == "" {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")

View file

@ -0,0 +1,35 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestTenantMiddlewareAllowsSystemEndpointsWithoutTenant(t *testing.T) {
next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := TenantSchemaMiddleware(nil)(next)
for _, path := range []string{"/api/health", "/api/openapi.json"} {
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
if response.Code != http.StatusOK {
t.Fatalf("%s returned %d", path, response.Code)
}
}
}
func TestTenantMiddlewareStillRequiresTenantForDataEndpoints(t *testing.T) {
handler := TenantSchemaMiddleware(nil)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
t.Fatal("data endpoint reached without a tenant")
}))
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/jobs", nil))
if response.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", response.Code)
}
}

View file

@ -0,0 +1,48 @@
package openapi_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/danyel/go-guess/backend/internal/web/handler"
"github.com/danyel/go-guess/backend/internal/web/router"
)
func TestRuntimeContractContainsRegisteredTypedRoutes(t *testing.T) {
api := router.New(
handler.New(nil, nil, nil, nil, nil, nil, nil, nil, 10, "http://localhost:5173"),
nil,
"http://localhost:5173",
)
response := httptest.NewRecorder()
api.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/openapi.json", nil))
if response.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", response.Code, response.Body.String())
}
var document map[string]any
if err := json.NewDecoder(response.Body).Decode(&document); err != nil {
t.Fatal(err)
}
if document["openapi"] != "3.0.3" {
t.Fatalf("unexpected OpenAPI version: %v", document["openapi"])
}
paths := document["paths"].(map[string]any)
if len(paths) < 30 {
t.Fatalf("contract contains only %d paths", len(paths))
}
jobs := paths["/api/jobs"].(map[string]any)
createJob := jobs["post"].(map[string]any)
if _, ok := createJob["requestBody"]; !ok {
t.Fatal("create-job request schema is missing")
}
if _, ok := createJob["security"]; !ok {
t.Fatal("create-job bearer security is missing")
}
contract := paths["/api/openapi.json"].(map[string]any)["get"].(map[string]any)
if parameters := contract["parameters"].([]any); len(parameters) != 0 {
t.Fatalf("contract endpoint unexpectedly requires parameters: %#v", parameters)
}
}

View file

@ -0,0 +1,243 @@
package openapi
import (
"encoding/json"
"net/http"
"reflect"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
type Operation struct {
Summary string
Request any
Response any
SuccessStatus int
Protected bool
RequestContentType string
ResponseContentType string
QueryParameters []string
NoTenant bool
}
type Builder struct {
paths map[string]any
schemas map[string]any
}
type ParticipantForm struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
Birthday string `json:"birthday,omitempty"`
Email string `json:"email"`
ContactInfo string `json:"contactInfo"`
Photo []byte `json:"photo,omitempty" openapi:"binary"`
CV []byte `json:"cv,omitempty" openapi:"binary"`
}
type CVForm struct {
CV []byte `json:"cv" openapi:"binary"`
}
func New() *Builder {
return &Builder{paths: make(map[string]any), schemas: make(map[string]any)}
}
func (b *Builder) Add(method, path string, operation Operation) {
method = strings.ToLower(method)
status := operation.SuccessStatus
if status == 0 {
status = http.StatusOK
}
contentType := operation.ResponseContentType
if contentType == "" {
contentType = "application/json"
}
success := map[string]any{"description": http.StatusText(status)}
if operation.Response != nil {
responseFormat := ""
if contentType == "application/octet-stream" {
responseFormat = "binary"
}
success["content"] = map[string]any{
contentType: map[string]any{"schema": b.schema(reflect.TypeOf(operation.Response), responseFormat)},
}
}
responses := map[string]any{
strconv.Itoa(status): success,
"default": map[string]any{
"description": "Error response",
"content": map[string]any{
"application/json": map[string]any{
"schema": b.schema(reflect.TypeOf(errorResponse{}), ""),
},
},
},
}
value := map[string]any{
"operationId": operationID(method, path),
"summary": operation.Summary,
"tags": []string{tagFor(path)},
"parameters": b.parameters(path, operation.QueryParameters, !operation.NoTenant),
"responses": responses,
}
if operation.Protected {
value["security"] = []map[string][]string{{"bearerAuth": {}}}
}
if operation.Request != nil {
requestContentType := operation.RequestContentType
if requestContentType == "" {
requestContentType = "application/json"
}
value["requestBody"] = map[string]any{
"required": true,
"content": map[string]any{
requestContentType: map[string]any{
"schema": b.schema(reflect.TypeOf(operation.Request), ""),
},
},
}
}
pathItem, _ := b.paths[path].(map[string]any)
if pathItem == nil {
pathItem = make(map[string]any)
b.paths[path] = pathItem
}
pathItem[method] = value
}
func (b *Builder) ServeHTTP(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(b.Document())
}
func (b *Builder) Document() map[string]any {
return map[string]any{
"openapi": "3.0.3",
"info": map[string]any{
"title": "Go Guess API",
"version": "1.0.0",
},
"servers": []map[string]string{{"url": "/"}},
"paths": b.paths,
"components": map[string]any{
"securitySchemes": map[string]any{
"bearerAuth": map[string]any{"type": "http", "scheme": "bearer", "bearerFormat": "JWT"},
},
"schemas": b.schemas,
},
}
}
func (b *Builder) parameters(path string, queries []string, tenantRequired bool) []map[string]any {
result := make([]map[string]any, 0)
if tenantRequired {
result = append(result, map[string]any{
"name": "X-Tenant-Id", "in": "header", "required": true,
"schema": map[string]any{"type": "string"},
})
}
for _, match := range regexp.MustCompile(`\{([^}]+)\}`).FindAllStringSubmatch(path, -1) {
name := match[1]
kind := "integer"
if name == "token" {
kind = "string"
}
result = append(result, map[string]any{
"name": name, "in": "path", "required": true,
"schema": map[string]any{"type": kind},
})
}
for _, name := range queries {
result = append(result, map[string]any{
"name": name, "in": "query", "required": false,
"schema": map[string]any{"type": "string"},
})
}
return result
}
func (b *Builder) schema(value reflect.Type, format string) map[string]any {
for value.Kind() == reflect.Pointer {
value = value.Elem()
}
if format == "binary" {
return map[string]any{"type": "string", "format": "binary"}
}
if value == reflect.TypeOf(time.Time{}) {
return map[string]any{"type": "string", "format": "date-time"}
}
switch value.Kind() {
case reflect.Struct:
name := value.Name()
if _, exists := b.schemas[name]; !exists {
b.schemas[name] = map[string]any{}
properties := make(map[string]any)
required := make([]string, 0)
for index := range value.NumField() {
field := value.Field(index)
tag := strings.Split(field.Tag.Get("json"), ",")
if tag[0] == "-" || tag[0] == "" {
continue
}
properties[tag[0]] = b.schema(field.Type, field.Tag.Get("openapi"))
if !contains(tag[1:], "omitempty") {
required = append(required, tag[0])
}
}
sort.Strings(required)
component := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
component["required"] = required
}
b.schemas[name] = component
}
return map[string]any{"$ref": "#/components/schemas/" + name}
case reflect.Slice, reflect.Array:
if value.Elem().Kind() == reflect.Uint8 {
return map[string]any{"type": "string", "format": "byte"}
}
return map[string]any{"type": "array", "items": b.schema(value.Elem(), "")}
case reflect.Map:
return map[string]any{"type": "object", "additionalProperties": b.schema(value.Elem(), "")}
case reflect.Bool:
return map[string]any{"type": "boolean"}
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64,
reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return map[string]any{"type": "integer"}
case reflect.Float32, reflect.Float64:
return map[string]any{"type": "number"}
default:
return map[string]any{"type": "string"}
}
}
type errorResponse struct {
Error string `json:"error"`
}
func contains(values []string, expected string) bool {
for _, value := range values {
if value == expected {
return true
}
}
return false
}
func operationID(method, path string) string {
value := strings.NewReplacer("/", "_", "{", "", "}", "", "-", "_").Replace(path)
return method + strings.Trim(value, "_")
}
func tagFor(path string) string {
parts := strings.Split(strings.TrimPrefix(path, "/api/"), "/")
if len(parts) == 0 || parts[0] == "" {
return "system"
}
return parts[0]
}

View file

@ -0,0 +1,79 @@
package openapi
import (
"net/http"
"testing"
)
type exampleRequest struct {
Name string `json:"name"`
}
type exampleResponse struct {
ID uint `json:"id"`
}
func TestBuilderCreatesTypedOperationFromRouteRegistration(t *testing.T) {
builder := New()
builder.Add(http.MethodPost, "/api/examples/{id}", Operation{
Summary: "Create an example", Request: exampleRequest{}, Response: exampleResponse{},
SuccessStatus: http.StatusCreated, Protected: true,
})
document := builder.Document()
paths := document["paths"].(map[string]any)
path := paths["/api/examples/{id}"].(map[string]any)
operation := path["post"].(map[string]any)
if operation["operationId"] != "postapi_examples_id" {
t.Fatalf("unexpected operation ID: %v", operation["operationId"])
}
if _, ok := operation["security"]; !ok {
t.Fatal("protected operation does not declare bearer security")
}
responses := operation["responses"].(map[string]any)
if _, ok := responses["201"]; !ok {
t.Fatalf("missing 201 response: %#v", responses)
}
components := document["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
if _, ok := schemas["exampleRequest"]; !ok {
t.Fatalf("request schema was not generated: %#v", schemas)
}
if _, ok := schemas["exampleResponse"]; !ok {
t.Fatalf("response schema was not generated: %#v", schemas)
}
}
func TestBuilderDescribesMultipartBinaryFields(t *testing.T) {
builder := New()
builder.Add(http.MethodPut, "/api/participants/{id}/cv", Operation{
Summary: "Replace a CV", Request: CVForm{}, Response: map[string]string{},
RequestContentType: "multipart/form-data",
})
document := builder.Document()
components := document["components"].(map[string]any)
schemas := components["schemas"].(map[string]any)
cv := schemas["CVForm"].(map[string]any)
properties := cv["properties"].(map[string]any)
field := properties["cv"].(map[string]any)
if field["type"] != "string" || field["format"] != "binary" {
t.Fatalf("unexpected CV field schema: %#v", field)
}
}
func TestBuilderCanExposeSystemOperationsWithoutTenantHeader(t *testing.T) {
builder := New()
builder.Add(http.MethodGet, "/api/openapi.json", Operation{
Summary: "Get contract", Response: map[string]any{}, NoTenant: true,
})
document := builder.Document()
paths := document["paths"].(map[string]any)
path := paths["/api/openapi.json"].(map[string]any)
operation := path["get"].(map[string]any)
parameters := operation["parameters"].([]map[string]any)
if len(parameters) != 0 {
t.Fatalf("system operation unexpectedly requires parameters: %#v", parameters)
}
}

View file

@ -7,67 +7,128 @@ import (
"strings" "strings"
"time" "time"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security" "github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/web/handler" "github.com/danyel/go-guess/backend/internal/web/handler"
mw "github.com/danyel/go-guess/backend/internal/web/middleware" mw "github.com/danyel/go-guess/backend/internal/web/middleware"
webmodel "github.com/danyel/go-guess/backend/internal/web/model"
"github.com/danyel/go-guess/backend/internal/web/openapi"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/v5/middleware"
) )
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler { func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter() router := chi.NewRouter()
spec := openapi.New()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5)) router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL)) router.Use(cors(frontendURL))
router.Use(mw.TenantSchemaMiddleware(h.Db())) router.Use(mw.TenantSchemaMiddleware(h.Db()))
router.Use(middleware.Timeout(2 * time.Minute)) router.Use(middleware.Timeout(2 * time.Minute))
router.Get("/api/health", h.Health) register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP,
router.Post("/api/auth/login", h.Login) openapi.Operation{Summary: "Get the current OpenAPI contract", Response: map[string]any{}, NoTenant: true})
router.Get("/api/interviews/{token}", h.GetInterview) register(router, spec, false, http.MethodGet, "/api/health", h.Health,
router.Post("/api/interviews/{token}/accept", h.AcceptInterview) openapi.Operation{Summary: "Check API health", Response: map[string]string{}, NoTenant: true})
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer) register(router, spec, false, http.MethodPost, "/api/auth/login", h.Login,
router.Post("/api/interviews/{token}/finish", h.FinishInterview) openapi.Operation{Summary: "Authenticate an interviewer", Request: webmodel.LoginRequest{}, Response: webmodel.LoginResponse{}})
router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting) register(router, spec, false, http.MethodGet, "/api/interviews/{token}", h.GetInterview,
router.Get("/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents) openapi.Operation{Summary: "Get a participant assessment", Response: webmodel.InterviewResponse{}})
register(router, spec, false, http.MethodPost, "/api/interviews/{token}/accept", h.AcceptInterview,
openapi.Operation{Summary: "Accept a participant assessment", Response: webmodel.InterviewResponse{}})
register(router, spec, false, http.MethodPut, "/api/interviews/{token}/answers/{questionId}", h.SaveAnswer,
openapi.Operation{Summary: "Save a participant answer", Request: webmodel.AnswerRequest{}, SuccessStatus: http.StatusNoContent})
register(router, spec, false, http.MethodPost, "/api/interviews/{token}/finish", h.FinishInterview,
openapi.Operation{Summary: "Finish a participant assessment", Response: webmodel.InterviewResponse{}})
register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}", h.GetParticipantMeeting,
openapi.Operation{Summary: "Get a participant meeting", Response: webmodel.ParticipantMeetingResponse{}})
register(router, spec, false, http.MethodGet, "/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents,
openapi.Operation{Summary: "Stream participant meeting events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
router.Group(func(protected chi.Router) { router.Group(func(protected chi.Router) {
protected.Use(authenticate(tokens)) protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs) register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
protected.Post("/api/jobs", h.CreateJob) openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
protected.Get("/api/jobs/{id}", h.GetJob) register(protected, spec, true, http.MethodPost, "/api/jobs", h.CreateJob,
protected.Patch("/api/jobs/{id}", h.UpdateJob) openapi.Operation{Summary: "Create a job posting", Request: webmodel.JobRequest{}, Response: webmodel.JobResponse{}, SuccessStatus: http.StatusCreated})
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion) register(protected, spec, true, http.MethodGet, "/api/jobs/{id}", h.GetJob,
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion) openapi.Operation{Summary: "Get a job posting", Response: webmodel.JobResponse{}})
protected.Get("/api/jobs/{id}/candidates", h.Candidates) register(protected, spec, true, http.MethodPatch, "/api/jobs/{id}", h.UpdateJob,
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations) openapi.Operation{Summary: "Update job status and duration", Request: webmodel.JobStatusRequest{}, Response: webmodel.JobResponse{}})
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation) register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/questions/{questionId}", h.AttachQuestion,
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation) openapi.Operation{Summary: "Attach a question to a job", SuccessStatus: http.StatusNoContent})
protected.Patch("/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome) register(protected, spec, true, http.MethodDelete, "/api/jobs/{id}/questions/{questionId}", h.DetachQuestion,
protected.Get("/api/jobs/{id}/interviews", h.ListScheduledInterviews) openapi.Operation{Summary: "Detach a question from a job", SuccessStatus: http.StatusNoContent})
protected.Post("/api/jobs/{id}/interviews", h.CreateScheduledInterview) register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/candidates", h.Candidates,
protected.Get("/api/scheduled-interviews/{id}", h.GetScheduledInterview) openapi.Operation{Summary: "List matching candidates", Response: []webmodel.CandidateResponse{}})
protected.Patch("/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus) register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/invitations", h.ListInvitations,
protected.Patch("/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument) openapi.Operation{Summary: "List job invitations", Response: []webmodel.InvitationResponse{}})
protected.Get("/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes) register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/invitations", h.CreateInvitation,
protected.Post("/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote) openapi.Operation{Summary: "Create a participant invitation", Request: webmodel.InvitationRequest{}, Response: webmodel.InvitationResponse{}, SuccessStatus: http.StatusCreated})
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents) register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation,
protected.Get("/api/users", h.ListUsers) openapi.Operation{Summary: "Review invitation answers", Response: webmodel.InvitationReviewResponse{}})
protected.Post("/api/users", h.CreateUser) register(protected, spec, true, http.MethodPatch, "/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome,
protected.Get("/api/invitations", h.ListInbox) openapi.Operation{Summary: "Set an invitation outcome", Request: webmodel.InvitationOutcomeRequest{}, Response: webmodel.InvitationResponse{}})
protected.Patch("/api/invitations/{id}", h.UpdateInbox) register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/interviews", h.ListScheduledInterviews,
protected.Get("/api/schedule", h.ListCalendar) openapi.Operation{Summary: "List scheduled job interviews", Response: []webmodel.ScheduledInterviewResponse{}})
protected.Get("/api/questions", h.ListQuestions) register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/interviews", h.CreateScheduledInterview,
protected.Post("/api/questions", h.CreateQuestion) openapi.Operation{Summary: "Schedule an interview", Request: webmodel.CreateScheduledInterviewRequest{}, Response: webmodel.ScheduledInterviewResponse{}, SuccessStatus: http.StatusCreated})
protected.Put("/api/questions/{id}", h.UpdateQuestion) register(protected, spec, true, http.MethodGet, "/api/scheduled-interviews/{id}", h.GetScheduledInterview,
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated) openapi.Operation{Summary: "Get a scheduled interview", Response: webmodel.ScheduledInterviewResponse{}})
protected.Get("/api/participants", h.ListParticipants) register(protected, spec, true, http.MethodPatch, "/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus,
protected.Post("/api/participants", h.CreateParticipant) openapi.Operation{Summary: "Update interview status", Request: webmodel.InterviewStatusRequest{}, Response: webmodel.ScheduledInterviewResponse{}})
protected.Get("/api/participants/{id}", h.GetParticipant) register(protected, spec, true, http.MethodPatch, "/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument,
protected.Put("/api/participants/{id}/cv", h.UpdateParticipantCV) openapi.Operation{Summary: "Update shared interview document", Request: webmodel.InterviewDocumentRequest{}, Response: webmodel.ScheduledInterviewResponse{}})
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto) register(protected, spec, true, http.MethodGet, "/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes,
protected.Get("/api/participants/{id}/cv", h.ParticipantCV) openapi.Operation{Summary: "List interview notes", Response: []webmodel.InterviewNoteResponse{}})
register(protected, spec, true, http.MethodPost, "/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote,
openapi.Operation{Summary: "Create an interview note", Request: webmodel.InterviewNoteRequest{}, Response: webmodel.InterviewNoteResponse{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodGet, "/api/scheduled-interviews/{id}/events", h.InterviewEvents,
openapi.Operation{Summary: "Stream interview events", Response: eventbus.InterviewEvent{}, ResponseContentType: "text/event-stream"})
register(protected, spec, true, http.MethodGet, "/api/users", h.ListUsers,
openapi.Operation{Summary: "List interviewer users", Response: []webmodel.User{}})
register(protected, spec, true, http.MethodPost, "/api/users", h.CreateUser,
openapi.Operation{Summary: "Create an interviewer user", Request: webmodel.CreateUserRequest{}, Response: webmodel.User{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodGet, "/api/invitations", h.ListInbox,
openapi.Operation{Summary: "List interviewer invitations", Response: []webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/invitations/{id}", h.UpdateInbox,
openapi.Operation{Summary: "Respond to an interviewer invitation", Request: webmodel.AttendeeStatusRequest{}, Response: webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodGet, "/api/schedule", h.ListCalendar,
openapi.Operation{Summary: "List accepted interview schedule", Response: []webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodGet, "/api/questions", h.ListQuestions,
openapi.Operation{Summary: "List questions", Response: []webmodel.QuestionResponse{}, QueryParameters: []string{"search"}})
register(protected, spec, true, http.MethodPost, "/api/questions", h.CreateQuestion,
openapi.Operation{Summary: "Create a question", Request: webmodel.QuestionRequest{}, Response: webmodel.QuestionResponse{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodPut, "/api/questions/{id}", h.UpdateQuestion,
openapi.Operation{Summary: "Update a question", Request: webmodel.QuestionRequest{}, Response: webmodel.QuestionResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/questions/{id}", h.SetQuestionDeprecated,
openapi.Operation{Summary: "Change question deprecation", Request: webmodel.QuestionStatusRequest{}, Response: webmodel.QuestionResponse{}})
register(protected, spec, true, http.MethodGet, "/api/participants", h.ListParticipants,
openapi.Operation{Summary: "List participants", Response: []webmodel.ParticipantResponse{}})
register(protected, spec, true, http.MethodPost, "/api/participants", h.CreateParticipant,
openapi.Operation{Summary: "Create a participant", Request: openapi.ParticipantForm{}, Response: webmodel.ParticipantResponse{}, SuccessStatus: http.StatusCreated, RequestContentType: "multipart/form-data"})
register(protected, spec, true, http.MethodGet, "/api/participants/{id}", h.GetParticipant,
openapi.Operation{Summary: "Get a participant", Response: webmodel.ParticipantResponse{}})
register(protected, spec, true, http.MethodPut, "/api/participants/{id}/cv", h.UpdateParticipantCV,
openapi.Operation{Summary: "Replace a participant CV", Request: openapi.CVForm{}, Response: webmodel.ParticipantResponse{}, RequestContentType: "multipart/form-data"})
register(protected, spec, true, http.MethodGet, "/api/participants/{id}/photo", h.ParticipantPhoto,
openapi.Operation{Summary: "Download a participant photo", Response: []byte{}, ResponseContentType: "application/octet-stream"})
register(protected, spec, true, http.MethodGet, "/api/participants/{id}/cv", h.ParticipantCV,
openapi.Operation{Summary: "Download a participant CV", Response: []byte{}, ResponseContentType: "application/octet-stream"})
}) })
return router return router
} }
func register(
router chi.Router,
spec *openapi.Builder,
protected bool,
method, path string,
handler http.HandlerFunc,
operation openapi.Operation,
) {
operation.Protected = protected
spec.Add(method, path, operation)
router.MethodFunc(method, path, handler)
}
func writeUnauthorized(w http.ResponseWriter, message string) { func writeUnauthorized(w http.ResponseWriter, message string) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)

View file

@ -52,40 +52,11 @@ JSON, file, or event-stream request clears authentication and returns the user t
`/login`. The app also schedules logout from the JWT expiration claim so an idle `/login`. The app also schedules logout from the JWT expiration claim so an idle
expired session cannot remain on a protected page. expired session cannot remain on a protected page.
Supported routes: The machine-readable OpenAPI 3.0 contract at `/api/openapi.json` is the source
of truth for supported routes and schemas. It is assembled by the backend from
- `POST /api/auth/login` the same typed calls that register Chi routes, so there is no generated file or
- `GET|POST /api/jobs` manual update command. Do not duplicate a route inventory here; inspect the
- `GET /api/jobs/:id` contract instead.
- `PATCH /api/jobs/:id` for interview status and duration
- `POST|DELETE /api/jobs/:id/questions/:questionId`
- `GET /api/jobs/:id/candidates`
- `GET|POST /api/jobs/:id/invitations`
- `GET /api/jobs/:jobId/invitations/:invitationId` for protected answer review
- `PATCH /api/jobs/:jobId/invitations/:invitationId/outcome`
- `GET|POST /api/jobs/:jobId/interviews`
- `GET|POST /api/questions` (`GET` accepts `?search=`)
- `PUT /api/questions/:id` to update question text, type, options, reference answer, and code snippet
- `PATCH /api/questions/:id` to deprecate or restore a question
- `GET|POST /api/participants`
- `GET /api/participants/:id`
- `PUT /api/participants/:id/cv`
- `GET /api/participants/:id/photo`
- `GET /api/participants/:id/cv`
- `GET /api/interviews/:token`
- `POST /api/interviews/:token/accept`
- `PUT /api/interviews/:token/answers/:questionId`
- `POST /api/interviews/:token/finish`
- `GET|POST /api/users`
- `GET /api/invitations` and `PATCH /api/invitations/:interviewId`
- `GET /api/schedule`
- `GET /api/scheduled-interviews/:id`
- `PATCH /api/scheduled-interviews/:id/status`
- `PATCH /api/scheduled-interviews/:id/document`
- `GET|POST /api/scheduled-interviews/:id/notes`
- `GET /api/scheduled-interviews/:id/events` (authenticated fetch-based SSE)
- `GET /api/participant-meetings/:token` (public)
- `GET /api/participant-meetings/:token/events` (token-authorized SSE)
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`, Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`. `contactInfo`, `photo`, and `cv`.