diff --git a/internal/app/app.go b/internal/app/app.go index 7b7ca12..b4dbcbb 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -14,6 +14,7 @@ import ( "github.com/hidetzu/prism-api/internal/config" "github.com/hidetzu/prism-api/internal/httpapi/handler" "github.com/hidetzu/prism-api/internal/httpapi/middleware" + "github.com/hidetzu/prism-api/internal/usecase" ) // App holds runtime dependencies and the configured HTTP server. @@ -32,6 +33,9 @@ func New(cfg *config.Config, logger *slog.Logger) *App { mux.HandleFunc("GET /readyz", health.Ready) mux.HandleFunc("GET /version", health.Version) + analyzeHandler := handler.NewAnalyzeHandler(usecase.NewAnalyzer()) + mux.HandleFunc("POST /v1/analyze", analyzeHandler.Handle) + chain := middleware.Chain( mux, middleware.RequestID(), diff --git a/internal/httpapi/handler/analyze.go b/internal/httpapi/handler/analyze.go new file mode 100644 index 0000000..8a33634 --- /dev/null +++ b/internal/httpapi/handler/analyze.go @@ -0,0 +1,119 @@ +package handler + +import ( + "context" + "encoding/json" + "errors" + "net/http" + + "github.com/hidetzu/prism/pkg/prism" + + "github.com/hidetzu/prism-api/internal/httpapi/middleware" + "github.com/hidetzu/prism-api/internal/httpapi/response" + "github.com/hidetzu/prism-api/internal/usecase" + "github.com/hidetzu/prism-api/internal/validation" +) + +// AnalyzeRequest is the JSON body accepted by POST /v1/analyze. +type AnalyzeRequest struct { + PullRequestURL string `json:"pull_request_url"` +} + +// Validate enforces that pull_request_url is present and a well-formed +// GitHub pull request URL. Handlers call this before invoking the usecase. +func (r AnalyzeRequest) Validate() error { + if err := validation.Required("pull_request_url", r.PullRequestURL); err != nil { + return err + } + return validation.GitHubPullRequestURL(r.PullRequestURL) +} + +// AnalyzeUsecase is the handler-side view of the usecase dependency. It is +// defined here (not in internal/usecase) so the handler package follows +// Go's "accept interfaces, return structs" idiom per +// docs/development_rules.md §10. The concrete implementation is +// usecase.Analyzer. +type AnalyzeUsecase interface { + Analyze(ctx context.Context, in usecase.AnalyzeInput) (prism.Result, error) +} + +// AnalyzeHandler serves POST /v1/analyze. It decodes the request, validates +// it, delegates to the usecase, and writes either the pkg/prism Result as +// {"result": ...} or a standard error body. +type AnalyzeHandler struct { + uc AnalyzeUsecase +} + +// NewAnalyzeHandler constructs an AnalyzeHandler with the provided usecase. +func NewAnalyzeHandler(uc AnalyzeUsecase) *AnalyzeHandler { + return &AnalyzeHandler{uc: uc} +} + +// Handle serves a single POST /v1/analyze request. +func (h *AnalyzeHandler) Handle(w http.ResponseWriter, r *http.Request) { + requestID := middleware.RequestIDFrom(r.Context()) + + var req AnalyzeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + // body_limit middleware wraps r.Body with http.MaxBytesReader; when + // the read crosses the limit the decoder surfaces *http.MaxBytesError. + // Translate to 413 so the rejection semantics match the early-path + // rejection performed by body_limit itself. + var mbe *http.MaxBytesError + if errors.As(err, &mbe) { + response.WriteError(w, requestID, response.CodePayloadTooLarge, + "request body exceeds the configured limit") + return + } + response.WriteError(w, requestID, response.CodeInvalidInput, + "request body must be a valid JSON object") + return + } + + if err := req.Validate(); err != nil { + var verr *validation.Error + if errors.As(err, &verr) { + response.WriteError(w, requestID, response.CodeInvalidInput, verr.Error()) + return + } + response.WriteError(w, requestID, response.CodeInvalidInput, err.Error()) + return + } + + result, err := h.uc.Analyze(r.Context(), usecase.AnalyzeInput{ + PullRequestURL: req.PullRequestURL, + }) + if err != nil { + writeAnalyzeUsecaseError(w, requestID, err) + return + } + + // pkg/prism.Result already has the JSON tags the response contract + // wants (pull_request, analysis, changed_files), so we can serialize + // it directly without a translation layer. Per Phase 2 instruction §7 + // the envelope is {"result": }. + _ = response.WriteJSON(w, http.StatusOK, map[string]any{"result": result}) +} + +// writeAnalyzeUsecaseError maps pkg/prism sentinel errors to the canonical +// response.Code values. Unknown errors become CodeInternalError without +// leaking the underlying message to the client. +func writeAnalyzeUsecaseError(w http.ResponseWriter, requestID string, err error) { + switch { + case errors.Is(err, prism.ErrInvalidInput): + response.WriteError(w, requestID, response.CodeInvalidInput, + "the pull request input could not be processed") + case errors.Is(err, prism.ErrUnsupportedProvider): + response.WriteError(w, requestID, response.CodeUnsupportedProvider, + "the requested provider is not supported") + case errors.Is(err, prism.ErrAuthRequired): + response.WriteError(w, requestID, response.CodeAuthRequired, + "authentication is required to access this repository") + case errors.Is(err, prism.ErrUpstreamFailure): + response.WriteError(w, requestID, response.CodeUpstreamFailure, + "upstream service is temporarily unavailable") + default: + response.WriteError(w, requestID, response.CodeInternalError, + "internal server error") + } +} diff --git a/internal/httpapi/handler/analyze_test.go b/internal/httpapi/handler/analyze_test.go new file mode 100644 index 0000000..a9c138d --- /dev/null +++ b/internal/httpapi/handler/analyze_test.go @@ -0,0 +1,279 @@ +package handler + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/hidetzu/prism/pkg/prism" + + "github.com/hidetzu/prism-api/internal/usecase" +) + +// fakeAnalyzeUsecase is a test double implementing the AnalyzeUsecase +// interface. Tests set result/err to shape the response and read gotInput +// after invocation to assert on what the handler forwarded. +type fakeAnalyzeUsecase struct { + result prism.Result + err error + gotInput usecase.AnalyzeInput + calls int +} + +func (f *fakeAnalyzeUsecase) Analyze(_ context.Context, in usecase.AnalyzeInput) (prism.Result, error) { + f.calls++ + f.gotInput = in + return f.result, f.err +} + +func serve(h *AnalyzeHandler, body string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodPost, "/v1/analyze", strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.Handle(rec, req) + return rec +} + +func TestAnalyzeHandler_Success(t *testing.T) { + uc := &fakeAnalyzeUsecase{ + result: prism.Result{ + PR: prism.PRInfo{ + Provider: "github", + Repository: "owner/repo", + ID: "123", + Title: "Example PR", + Author: "alice", + URL: "https://github.com/owner/repo/pull/123", + }, + Analysis: prism.AnalysisResult{ + ChangeType: "feature", + RiskLevel: "low", + Summary: "Adds a small feature", + }, + Files: []prism.ChangedFile{ + {Path: "a.go", Status: "modified", Additions: 10, Deletions: 2, Language: "go"}, + }, + }, + } + h := NewAnalyzeHandler(uc) + + rec := serve(h, `{"pull_request_url":"https://github.com/owner/repo/pull/123"}`) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if uc.calls != 1 { + t.Errorf("usecase called %d times, want 1", uc.calls) + } + if uc.gotInput.PullRequestURL != "https://github.com/owner/repo/pull/123" { + t.Errorf("usecase.gotInput.PullRequestURL = %q", uc.gotInput.PullRequestURL) + } + + var body struct { + Result struct { + PullRequest struct { + Provider string `json:"provider"` + Repository string `json:"repository"` + ID string `json:"id"` + URL string `json:"url"` + } `json:"pull_request"` + Analysis struct { + ChangeType string `json:"change_type"` + RiskLevel string `json:"risk_level"` + Summary string `json:"summary"` + } `json:"analysis"` + ChangedFiles []struct { + Path string `json:"path"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + } `json:"changed_files"` + } `json:"result"` + } + if err := json.NewDecoder(rec.Body).Decode(&body); err != nil { + t.Fatalf("decode: %v", err) + } + if body.Result.PullRequest.Repository != "owner/repo" { + t.Errorf("pull_request.repository = %q", body.Result.PullRequest.Repository) + } + if body.Result.Analysis.ChangeType != "feature" { + t.Errorf("analysis.change_type = %q", body.Result.Analysis.ChangeType) + } + if len(body.Result.ChangedFiles) != 1 || body.Result.ChangedFiles[0].Path != "a.go" { + t.Errorf("changed_files = %+v", body.Result.ChangedFiles) + } +} + +func TestAnalyzeHandler_InvalidJSON(t *testing.T) { + uc := &fakeAnalyzeUsecase{} + h := NewAnalyzeHandler(uc) + + rec := serve(h, `{not json`) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + if uc.calls != 0 { + t.Errorf("usecase called %d times, want 0", uc.calls) + } + assertErrorCode(t, rec, "invalid_input") +} + +func TestAnalyzeHandler_MissingPullRequestURL(t *testing.T) { + uc := &fakeAnalyzeUsecase{} + h := NewAnalyzeHandler(uc) + + rec := serve(h, `{}`) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + if uc.calls != 0 { + t.Error("usecase must not be called when validation fails") + } + assertErrorCode(t, rec, "invalid_input") + // Error message should name the missing field. + if !strings.Contains(readErrorMessage(t, rec), "pull_request_url") { + t.Error("error.message must mention pull_request_url") + } +} + +func TestAnalyzeHandler_InvalidPullRequestURL(t *testing.T) { + cases := []struct { + name string + url string + }{ + {"http scheme", "http://github.com/owner/repo/pull/1"}, + {"wrong host", "https://gitlab.com/owner/repo/pull/1"}, + {"issues not pull", "https://github.com/owner/repo/issues/1"}, + {"missing number", "https://github.com/owner/repo/pull"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + uc := &fakeAnalyzeUsecase{} + h := NewAnalyzeHandler(uc) + + rec := serve(h, `{"pull_request_url":"`+tc.url+`"}`) + + if rec.Code != http.StatusBadRequest { + t.Errorf("status = %d, want 400", rec.Code) + } + if uc.calls != 0 { + t.Error("usecase must not be called when URL validation fails") + } + assertErrorCode(t, rec, "invalid_input") + }) + } +} + +func TestAnalyzeHandler_BodyTooLarge(t *testing.T) { + // Simulate the middleware's MaxBytesReader by wrapping Body directly + // in the request. When the handler reads past the limit the decoder + // returns *http.MaxBytesError, which must map to 413. + uc := &fakeAnalyzeUsecase{} + h := NewAnalyzeHandler(uc) + + payload := `{"pull_request_url":"https://github.com/owner/repo/pull/123"}` + req := httptest.NewRequest(http.MethodPost, "/v1/analyze", strings.NewReader(payload)) + rec := httptest.NewRecorder() + // Limit below payload size so Decode trips MaxBytesError. + req.Body = http.MaxBytesReader(rec, req.Body, 8) + h.Handle(rec, req) + + if rec.Code != http.StatusRequestEntityTooLarge { + t.Errorf("status = %d, want 413", rec.Code) + } + assertErrorCode(t, rec, "payload_too_large") + if uc.calls != 0 { + t.Error("usecase must not be called when body exceeds limit") + } +} + +func TestAnalyzeHandler_UsecaseErrorMapping(t *testing.T) { + cases := []struct { + name string + err error + wantCode string + wantHTTP int + }{ + {"invalid input", prism.ErrInvalidInput, "invalid_input", http.StatusBadRequest}, + {"unsupported provider", prism.ErrUnsupportedProvider, "unsupported_provider", http.StatusBadRequest}, + {"auth required", prism.ErrAuthRequired, "auth_required", http.StatusUnauthorized}, + {"upstream failure", prism.ErrUpstreamFailure, "upstream_failure", http.StatusBadGateway}, + {"unknown error", errors.New("boom"), "internal_error", http.StatusInternalServerError}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + uc := &fakeAnalyzeUsecase{err: tc.err} + h := NewAnalyzeHandler(uc) + + rec := serve(h, `{"pull_request_url":"https://github.com/owner/repo/pull/1"}`) + + if rec.Code != tc.wantHTTP { + t.Errorf("status = %d, want %d", rec.Code, tc.wantHTTP) + } + assertErrorCode(t, rec, tc.wantCode) + }) + } +} + +func TestAnalyzeRequest_Validate(t *testing.T) { + // Unit test for the Validate() method in isolation from the handler. + cases := []struct { + name string + req AnalyzeRequest + wantErr bool + }{ + {"valid", AnalyzeRequest{PullRequestURL: "https://github.com/owner/repo/pull/1"}, false}, + {"empty", AnalyzeRequest{PullRequestURL: ""}, true}, + {"whitespace only", AnalyzeRequest{PullRequestURL: " "}, true}, + {"not a github url", AnalyzeRequest{PullRequestURL: "https://example.com/foo"}, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := tc.req.Validate() + if (err != nil) != tc.wantErr { + t.Errorf("Validate() err = %v, wantErr = %v", err, tc.wantErr) + } + }) + } +} + +// assertErrorCode decodes the standard error envelope and checks error.code. +func assertErrorCode(t *testing.T, rec *httptest.ResponseRecorder, want string) { + t.Helper() + var body struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + } + // Decode from a buffered copy so other helpers on the same rec can read it too. + if err := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())).Decode(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + if body.Error.Code != want { + t.Errorf("error.code = %q, want %q", body.Error.Code, want) + } + if body.Error.Message == "" { + t.Error("error.message must be non-empty") + } +} + +// readErrorMessage decodes error.message from the recorder body. +func readErrorMessage(t *testing.T, rec *httptest.ResponseRecorder) string { + t.Helper() + var body struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(bytes.NewReader(rec.Body.Bytes())).Decode(&body); err != nil { + t.Fatalf("decode error body: %v", err) + } + return body.Error.Message +}