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.
- Update the directly related local documentation whenever behavior,
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
`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
`/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
```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 {
return func(next http.Handler) http.Handler {
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")
if tenantID == "" {
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"
"time"
"github.com/danyel/go-guess/backend/internal/eventbus"
"github.com/danyel/go-guess/backend/internal/security"
"github.com/danyel/go-guess/backend/internal/web/handler"
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/middleware"
)
func New(h *handler.Handler, tokens security.ITokenManager, frontendURL string) http.Handler {
router := chi.NewRouter()
spec := openapi.New()
router.Use(middleware.RequestID, middleware.RealIP, middleware.Recoverer, middleware.Compress(5))
router.Use(cors(frontendURL))
router.Use(mw.TenantSchemaMiddleware(h.Db()))
router.Use(middleware.Timeout(2 * time.Minute))
router.Get("/api/health", h.Health)
router.Post("/api/auth/login", h.Login)
router.Get("/api/interviews/{token}", h.GetInterview)
router.Post("/api/interviews/{token}/accept", h.AcceptInterview)
router.Put("/api/interviews/{token}/answers/{questionId}", h.SaveAnswer)
router.Post("/api/interviews/{token}/finish", h.FinishInterview)
router.Get("/api/participant-meetings/{token}", h.GetParticipantMeeting)
router.Get("/api/participant-meetings/{token}/events", h.ParticipantMeetingEvents)
register(router, spec, false, http.MethodGet, "/api/openapi.json", spec.ServeHTTP,
openapi.Operation{Summary: "Get the current OpenAPI contract", Response: map[string]any{}, NoTenant: true})
register(router, spec, false, http.MethodGet, "/api/health", h.Health,
openapi.Operation{Summary: "Check API health", Response: map[string]string{}, NoTenant: true})
register(router, spec, false, http.MethodPost, "/api/auth/login", h.Login,
openapi.Operation{Summary: "Authenticate an interviewer", Request: webmodel.LoginRequest{}, Response: webmodel.LoginResponse{}})
register(router, spec, false, http.MethodGet, "/api/interviews/{token}", h.GetInterview,
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) {
protected.Use(authenticate(tokens))
protected.Get("/api/jobs", h.ListJobs)
protected.Post("/api/jobs", h.CreateJob)
protected.Get("/api/jobs/{id}", h.GetJob)
protected.Patch("/api/jobs/{id}", h.UpdateJob)
protected.Post("/api/jobs/{id}/questions/{questionId}", h.AttachQuestion)
protected.Delete("/api/jobs/{id}/questions/{questionId}", h.DetachQuestion)
protected.Get("/api/jobs/{id}/candidates", h.Candidates)
protected.Get("/api/jobs/{id}/invitations", h.ListInvitations)
protected.Post("/api/jobs/{id}/invitations", h.CreateInvitation)
protected.Get("/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation)
protected.Patch("/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome)
protected.Get("/api/jobs/{id}/interviews", h.ListScheduledInterviews)
protected.Post("/api/jobs/{id}/interviews", h.CreateScheduledInterview)
protected.Get("/api/scheduled-interviews/{id}", h.GetScheduledInterview)
protected.Patch("/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus)
protected.Patch("/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument)
protected.Get("/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes)
protected.Post("/api/scheduled-interviews/{id}/notes", h.CreateInterviewNote)
protected.Get("/api/scheduled-interviews/{id}/events", h.InterviewEvents)
protected.Get("/api/users", h.ListUsers)
protected.Post("/api/users", h.CreateUser)
protected.Get("/api/invitations", h.ListInbox)
protected.Patch("/api/invitations/{id}", h.UpdateInbox)
protected.Get("/api/schedule", h.ListCalendar)
protected.Get("/api/questions", h.ListQuestions)
protected.Post("/api/questions", h.CreateQuestion)
protected.Put("/api/questions/{id}", h.UpdateQuestion)
protected.Patch("/api/questions/{id}", h.SetQuestionDeprecated)
protected.Get("/api/participants", h.ListParticipants)
protected.Post("/api/participants", h.CreateParticipant)
protected.Get("/api/participants/{id}", h.GetParticipant)
protected.Put("/api/participants/{id}/cv", h.UpdateParticipantCV)
protected.Get("/api/participants/{id}/photo", h.ParticipantPhoto)
protected.Get("/api/participants/{id}/cv", h.ParticipantCV)
register(protected, spec, true, http.MethodGet, "/api/jobs", h.ListJobs,
openapi.Operation{Summary: "List job postings", Response: []webmodel.JobResponse{}})
register(protected, spec, true, http.MethodPost, "/api/jobs", h.CreateJob,
openapi.Operation{Summary: "Create a job posting", Request: webmodel.JobRequest{}, Response: webmodel.JobResponse{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodGet, "/api/jobs/{id}", h.GetJob,
openapi.Operation{Summary: "Get a job posting", Response: webmodel.JobResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/jobs/{id}", h.UpdateJob,
openapi.Operation{Summary: "Update job status and duration", Request: webmodel.JobStatusRequest{}, Response: webmodel.JobResponse{}})
register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/questions/{questionId}", h.AttachQuestion,
openapi.Operation{Summary: "Attach a question to a job", SuccessStatus: http.StatusNoContent})
register(protected, spec, true, http.MethodDelete, "/api/jobs/{id}/questions/{questionId}", h.DetachQuestion,
openapi.Operation{Summary: "Detach a question from a job", SuccessStatus: http.StatusNoContent})
register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/candidates", h.Candidates,
openapi.Operation{Summary: "List matching candidates", Response: []webmodel.CandidateResponse{}})
register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/invitations", h.ListInvitations,
openapi.Operation{Summary: "List job invitations", Response: []webmodel.InvitationResponse{}})
register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/invitations", h.CreateInvitation,
openapi.Operation{Summary: "Create a participant invitation", Request: webmodel.InvitationRequest{}, Response: webmodel.InvitationResponse{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/invitations/{invitationId}", h.ReviewInvitation,
openapi.Operation{Summary: "Review invitation answers", Response: webmodel.InvitationReviewResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/jobs/{id}/invitations/{invitationId}/outcome", h.SetInvitationOutcome,
openapi.Operation{Summary: "Set an invitation outcome", Request: webmodel.InvitationOutcomeRequest{}, Response: webmodel.InvitationResponse{}})
register(protected, spec, true, http.MethodGet, "/api/jobs/{id}/interviews", h.ListScheduledInterviews,
openapi.Operation{Summary: "List scheduled job interviews", Response: []webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodPost, "/api/jobs/{id}/interviews", h.CreateScheduledInterview,
openapi.Operation{Summary: "Schedule an interview", Request: webmodel.CreateScheduledInterviewRequest{}, Response: webmodel.ScheduledInterviewResponse{}, SuccessStatus: http.StatusCreated})
register(protected, spec, true, http.MethodGet, "/api/scheduled-interviews/{id}", h.GetScheduledInterview,
openapi.Operation{Summary: "Get a scheduled interview", Response: webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/scheduled-interviews/{id}/status", h.UpdateScheduledInterviewStatus,
openapi.Operation{Summary: "Update interview status", Request: webmodel.InterviewStatusRequest{}, Response: webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodPatch, "/api/scheduled-interviews/{id}/document", h.UpdateScheduledInterviewDocument,
openapi.Operation{Summary: "Update shared interview document", Request: webmodel.InterviewDocumentRequest{}, Response: webmodel.ScheduledInterviewResponse{}})
register(protected, spec, true, http.MethodGet, "/api/scheduled-interviews/{id}/notes", h.ListInterviewNotes,
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
}
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) {
w.Header().Set("Content-Type", "application/json")
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
expired session cannot remain on a protected page.
Supported routes:
- `POST /api/auth/login`
- `GET|POST /api/jobs`
- `GET /api/jobs/:id`
- `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)
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
the same typed calls that register Chi routes, so there is no generated file or
manual update command. Do not duplicate a route inventory here; inspect the
contract instead.
Participant creation uses multipart fields `firstName`, `lastName`, `birthday`, `email`,
`contactInfo`, `photo`, and `cv`.