From 045f0b7cf27eaa5a5602d624d9514be8d9aa0fa4 Mon Sep 17 00:00:00 2001 From: Daniel Noulet Date: Thu, 1 Oct 2026 18:54:02 +0200 Subject: [PATCH] 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> --- .github/copilot-instructions.md | 3 + README.md | 13 + .../web/middleware/TenantSchemaMiddleware.go | 4 + .../middleware/TenantSchemaMiddleware_test.go | 35 +++ backend/internal/web/openapi/endpoint_test.go | 48 ++++ backend/internal/web/openapi/openapi.go | 243 ++++++++++++++++++ backend/internal/web/openapi/openapi_test.go | 79 ++++++ backend/internal/web/router/router.go | 145 ++++++++--- frontend/README.md | 39 +-- 9 files changed, 533 insertions(+), 76 deletions(-) create mode 100644 backend/internal/web/middleware/TenantSchemaMiddleware_test.go create mode 100644 backend/internal/web/openapi/endpoint_test.go create mode 100644 backend/internal/web/openapi/openapi.go create mode 100644 backend/internal/web/openapi/openapi_test.go diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 0192792..573141b 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -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:`. diff --git a/README.md b/README.md index b38df77..08c868f 100644 --- a/README.md +++ b/README.md @@ -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 +. 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 diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware.go b/backend/internal/web/middleware/TenantSchemaMiddleware.go index af4af7a..3dc78e2 100644 --- a/backend/internal/web/middleware/TenantSchemaMiddleware.go +++ b/backend/internal/web/middleware/TenantSchemaMiddleware.go @@ -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") diff --git a/backend/internal/web/middleware/TenantSchemaMiddleware_test.go b/backend/internal/web/middleware/TenantSchemaMiddleware_test.go new file mode 100644 index 0000000..d968acc --- /dev/null +++ b/backend/internal/web/middleware/TenantSchemaMiddleware_test.go @@ -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) + } +} diff --git a/backend/internal/web/openapi/endpoint_test.go b/backend/internal/web/openapi/endpoint_test.go new file mode 100644 index 0000000..b5a9136 --- /dev/null +++ b/backend/internal/web/openapi/endpoint_test.go @@ -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) + } +} diff --git a/backend/internal/web/openapi/openapi.go b/backend/internal/web/openapi/openapi.go new file mode 100644 index 0000000..e48f8d2 --- /dev/null +++ b/backend/internal/web/openapi/openapi.go @@ -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] +} diff --git a/backend/internal/web/openapi/openapi_test.go b/backend/internal/web/openapi/openapi_test.go new file mode 100644 index 0000000..301746f --- /dev/null +++ b/backend/internal/web/openapi/openapi_test.go @@ -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) + } +} diff --git a/backend/internal/web/router/router.go b/backend/internal/web/router/router.go index 4450227..3533ca7 100644 --- a/backend/internal/web/router/router.go +++ b/backend/internal/web/router/router.go @@ -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) diff --git a/frontend/README.md b/frontend/README.md index e8b872a..df57df8 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -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`.