From c6df34ae05772564e884ee71950f0bad5cb9a415 Mon Sep 17 00:00:00 2001 From: Parth576 Date: Wed, 25 Feb 2026 23:54:35 -0500 Subject: [PATCH 1/3] feat(api): add request ID and timeout middleware Add RequestIDMiddleware that generates UUID v4 (or propagates incoming X-Request-ID header), injects the ID into the request context, and sets it on the response header. Add TimeoutMiddleware that wraps the request context with a configurable deadline (default 60s) using context.WithTimeout. Update LoggingMiddleware to include request_id in log output when present in context. Wire both middleware into the router chain in order: CORS -> Request ID -> Timeout -> Logging -> routes. Assisted by the code-assist SOP --- .../context.md | 45 ++ .../logs/test_output.log | 606 ++++++++++++++++++ .../request-id-and-timeout-middleware/plan.md | 63 ++ .../progress.md | 37 ++ backend/internal/api/middleware.go | 73 ++- backend/internal/api/middleware_test.go | 211 ++++++ backend/internal/api/router.go | 9 +- backend/internal/api/router_test.go | 28 + 8 files changed, 1069 insertions(+), 3 deletions(-) create mode 100644 .agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md create mode 100644 .agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log create mode 100644 .agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md create mode 100644 .agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md new file mode 100644 index 0000000..b658aff --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/context.md @@ -0,0 +1,45 @@ +# Context: Request ID and Timeout Middleware + +## Project Structure +- Module: `github.com/parth/smolterms` +- Target package: `backend/internal/api/` +- Key files: `middleware.go`, `router.go` +- Test files: `middleware_test.go`, `router_test.go` + +## Existing Patterns + +### Middleware Pattern +- Simple middleware: `func(http.Handler) http.Handler` (e.g., `CORSMiddleware`) +- Parameterized middleware: returns `func(http.Handler) http.Handler` (e.g., `LoggingMiddleware(logger)`) +- Chain order in `NewRouter`: innermost applied first, outermost wraps last +- Current chain: `mux -> LoggingMiddleware -> CORSMiddleware` + +### Testing Pattern +- Package-level tests (`package api`) +- `httptest.NewRecorder()` + `httptest.NewRequest()` for unit tests +- `httptest.NewServer()` for integration tests (router_test.go) +- Table-driven subtests with `t.Run()` +- `bytes.Buffer` + `slog.NewTextHandler` for capturing log output + +### Response Writer Pattern +- `statusRecorder` wraps `http.ResponseWriter` to capture status code +- Write-once guard on `WriteHeader` + +## Requirements Summary + +1. **Request ID Middleware**: Generate UUID v4, set `X-Request-ID` header, respect incoming header, inject into context +2. **RequestIDFromContext helper**: Extract request ID from context +3. **Timeout Middleware**: `context.WithTimeout` on request context, configurable duration +4. **LoggingMiddleware update**: Include `request_id` in log output +5. **Router update**: Wire middleware in order: CORS -> RequestID -> Timeout -> Logging -> routes + +## Dependencies +- No `github.com/google/uuid` in go.mod - will use `crypto/rand` to generate UUID v4 (avoids adding dependency) +- No existing context key patterns in codebase - introduce from scratch + +## Design Decisions +- Use `crypto/rand` for UUID v4 generation (no new dependency needed) +- Unexported `contextKey` type to prevent collisions +- `TimeoutMiddleware(duration)` returns `func(http.Handler) http.Handler` (parameterized pattern, matching `LoggingMiddleware`) +- Do NOT use `http.TimeoutHandler` (known issues with response hijacking) +- Default timeout: 60 seconds diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log new file mode 100644 index 0000000..502833b --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/logs/test_output.log @@ -0,0 +1,606 @@ + github.com/parth/smolterms/backend/cmd/server coverage: 0.0% of statements +=== RUN TestAnalyze_CacheHit +--- PASS: TestAnalyze_CacheHit (0.00s) +=== RUN TestAnalyze_CacheMiss +--- PASS: TestAnalyze_CacheMiss (0.00s) +=== RUN TestAnalyze_NilCache +--- PASS: TestAnalyze_NilCache (0.00s) +=== RUN TestAnalyze_CacheGetError +--- PASS: TestAnalyze_CacheGetError (0.00s) +=== RUN TestAnalyze_CacheSetError +--- PASS: TestAnalyze_CacheSetError (0.00s) +=== RUN TestAnalyze_CacheKey_Correct +--- PASS: TestAnalyze_CacheKey_Correct (0.00s) +=== RUN TestAnalyze_NotPolicy_NoCache +--- PASS: TestAnalyze_NotPolicy_NoCache (0.00s) +=== RUN TestCacheKey_Format +--- PASS: TestCacheKey_Format (0.00s) +=== RUN TestCacheKey_Deterministic +--- PASS: TestCacheKey_Deterministic (0.00s) +=== RUN TestCacheKey_DifferentInputs +--- PASS: TestCacheKey_DifferentInputs (0.00s) +=== RUN TestAnalyze_HappyPath +--- PASS: TestAnalyze_HappyPath (0.00s) +=== RUN TestAnalyze_NotPolicy +--- PASS: TestAnalyze_NotPolicy (0.00s) +=== RUN TestAnalyze_EmptyHTML_NotPolicy +--- PASS: TestAnalyze_EmptyHTML_NotPolicy (0.00s) +=== RUN TestAnalyze_RAGStoreError +--- PASS: TestAnalyze_RAGStoreError (0.00s) +=== RUN TestAnalyze_RAGRetrieveError +--- PASS: TestAnalyze_RAGRetrieveError (0.00s) +=== RUN TestAnalyze_LLMError +--- PASS: TestAnalyze_LLMError (0.00s) +=== RUN TestAnalyze_LLMResponseParseError +--- PASS: TestAnalyze_LLMResponseParseError (0.00s) +=== RUN TestAnalyze_ContextCancelled +--- PASS: TestAnalyze_ContextCancelled (0.00s) +=== RUN TestAnalyze_LogsStages +--- PASS: TestAnalyze_LogsStages (0.00s) +=== RUN TestAnalyze_CorrectDependencyCalls +--- PASS: TestAnalyze_CorrectDependencyCalls (0.00s) +=== RUN TestNewAnalyzer_NilDependencies +=== RUN TestNewAnalyzer_NilDependencies/nil_rag +=== RUN TestNewAnalyzer_NilDependencies/nil_llm +--- PASS: TestNewAnalyzer_NilDependencies (0.00s) + --- PASS: TestNewAnalyzer_NilDependencies/nil_rag (0.00s) + --- PASS: TestNewAnalyzer_NilDependencies/nil_llm (0.00s) +=== RUN TestNewAnalyzer_DefaultLogger +--- PASS: TestNewAnalyzer_DefaultLogger (0.00s) +=== RUN TestContentHash_Deterministic +--- PASS: TestContentHash_Deterministic (0.00s) +=== RUN TestContentHash_Unique +--- PASS: TestContentHash_Unique (0.00s) +=== RUN TestParseLLMResponse_ValidJSON +--- PASS: TestParseLLMResponse_ValidJSON (0.00s) +=== RUN TestParseLLMResponse_JSONCodeBlock +--- PASS: TestParseLLMResponse_JSONCodeBlock (0.00s) +=== RUN TestParseLLMResponse_PlainCodeBlock +--- PASS: TestParseLLMResponse_PlainCodeBlock (0.00s) +=== RUN TestParseLLMResponse_InvalidJSON +=== RUN TestParseLLMResponse_InvalidJSON/malformed +=== RUN TestParseLLMResponse_InvalidJSON/empty +=== RUN TestParseLLMResponse_InvalidJSON/plain_text +--- PASS: TestParseLLMResponse_InvalidJSON (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/malformed (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/empty (0.00s) + --- PASS: TestParseLLMResponse_InvalidJSON/plain_text (0.00s) +=== RUN TestDetermineRiskLevel +=== RUN TestDetermineRiskLevel/#00 +=== RUN TestDetermineRiskLevel/#01 +=== RUN TestDetermineRiskLevel/#02 +=== RUN TestDetermineRiskLevel/#03 +=== RUN TestDetermineRiskLevel/#04 +=== RUN TestDetermineRiskLevel/#05 +=== RUN TestDetermineRiskLevel/#06 +=== RUN TestDetermineRiskLevel/#07 +--- PASS: TestDetermineRiskLevel (0.00s) + --- PASS: TestDetermineRiskLevel/#00 (0.00s) + --- PASS: TestDetermineRiskLevel/#01 (0.00s) + --- PASS: TestDetermineRiskLevel/#02 (0.00s) + --- PASS: TestDetermineRiskLevel/#03 (0.00s) + --- PASS: TestDetermineRiskLevel/#04 (0.00s) + --- PASS: TestDetermineRiskLevel/#05 (0.00s) + --- PASS: TestDetermineRiskLevel/#06 (0.00s) + --- PASS: TestDetermineRiskLevel/#07 (0.00s) +=== RUN TestAggregate_StandardScores +--- PASS: TestAggregate_StandardScores (0.00s) +=== RUN TestAggregate_ClampsHighScores +--- PASS: TestAggregate_ClampsHighScores (0.00s) +=== RUN TestAggregate_ClampsLowScores +--- PASS: TestAggregate_ClampsLowScores (0.00s) +=== RUN TestAggregate_ZeroValueDefaultsToOne +--- PASS: TestAggregate_ZeroValueDefaultsToOne (0.00s) +=== RUN TestAggregate_RoundingToOneDecimal +--- PASS: TestAggregate_RoundingToOneDecimal (0.00s) +=== RUN TestAggregate_NilKeyConcerns +--- PASS: TestAggregate_NilKeyConcerns (0.00s) +=== RUN TestAnalysisResult_JSONSerialization +--- PASS: TestAnalysisResult_JSONSerialization (0.00s) +=== RUN TestAnalysisRequest_JSONDeserialization +--- PASS: TestAnalysisRequest_JSONDeserialization (0.00s) +=== RUN TestRiskLevelConstants +--- PASS: TestRiskLevelConstants (0.00s) +=== RUN TestDimensionConstants +--- PASS: TestDimensionConstants (0.00s) +=== RUN TestAllDimensions_StableOrder +--- PASS: TestAllDimensions_StableOrder (0.00s) +=== RUN TestDimScore_JSONRoundTrip +--- PASS: TestDimScore_JSONRoundTrip (0.00s) +PASS +coverage: 93.3% of statements +ok github.com/parth/smolterms/backend/internal/analyzer 0.004s coverage: 93.3% of statements +=== RUN TestHealthHandler +=== RUN TestHealthHandler/returns_200_status +=== RUN TestHealthHandler/returns_status_ok_JSON_body +=== RUN TestHealthHandler/sets_content-type_to_application/json +=== RUN TestHealthHandler/reports_configured_services +=== RUN TestHealthHandler/reports_not_configured_when_API_keys_are_empty +--- PASS: TestHealthHandler (0.00s) + --- PASS: TestHealthHandler/returns_200_status (0.00s) + --- PASS: TestHealthHandler/returns_status_ok_JSON_body (0.00s) + --- PASS: TestHealthHandler/sets_content-type_to_application/json (0.00s) + --- PASS: TestHealthHandler/reports_configured_services (0.00s) + --- PASS: TestHealthHandler/reports_not_configured_when_API_keys_are_empty (0.00s) +=== RUN TestCORSMiddleware +=== RUN TestCORSMiddleware/sets_CORS_headers_on_regular_request +=== RUN TestCORSMiddleware/handles_OPTIONS_with_204_and_no_body +=== RUN TestCORSMiddleware/passes_through_to_next_handler_for_non-OPTIONS +=== RUN TestCORSMiddleware/does_not_call_next_handler_for_OPTIONS +--- PASS: TestCORSMiddleware (0.00s) + --- PASS: TestCORSMiddleware/sets_CORS_headers_on_regular_request (0.00s) + --- PASS: TestCORSMiddleware/handles_OPTIONS_with_204_and_no_body (0.00s) + --- PASS: TestCORSMiddleware/passes_through_to_next_handler_for_non-OPTIONS (0.00s) + --- PASS: TestCORSMiddleware/does_not_call_next_handler_for_OPTIONS (0.00s) +=== RUN TestLoggingMiddleware +=== RUN TestLoggingMiddleware/logs_method,_path,_status,_and_duration +=== RUN TestLoggingMiddleware/captures_correct_status_code_from_handler +=== RUN TestLoggingMiddleware/defaults_to_200_when_handler_does_not_call_WriteHeader +=== RUN TestLoggingMiddleware/includes_request_id_when_present_in_context +=== RUN TestLoggingMiddleware/omits_request_id_when_not_in_context +--- PASS: TestLoggingMiddleware (0.00s) + --- PASS: TestLoggingMiddleware/logs_method,_path,_status,_and_duration (0.00s) + --- PASS: TestLoggingMiddleware/captures_correct_status_code_from_handler (0.00s) + --- PASS: TestLoggingMiddleware/defaults_to_200_when_handler_does_not_call_WriteHeader (0.00s) + --- PASS: TestLoggingMiddleware/includes_request_id_when_present_in_context (0.00s) + --- PASS: TestLoggingMiddleware/omits_request_id_when_not_in_context (0.00s) +=== RUN TestRequestIDMiddleware +=== RUN TestRequestIDMiddleware/generates_UUID_when_no_X-Request-ID_header_present +=== RUN TestRequestIDMiddleware/uses_existing_X-Request-ID_header_value +=== RUN TestRequestIDMiddleware/injects_request_ID_into_context +=== RUN TestRequestIDMiddleware/calls_next_handler +--- PASS: TestRequestIDMiddleware (0.00s) + --- PASS: TestRequestIDMiddleware/generates_UUID_when_no_X-Request-ID_header_present (0.00s) + --- PASS: TestRequestIDMiddleware/uses_existing_X-Request-ID_header_value (0.00s) + --- PASS: TestRequestIDMiddleware/injects_request_ID_into_context (0.00s) + --- PASS: TestRequestIDMiddleware/calls_next_handler (0.00s) +=== RUN TestRequestIDFromContext +=== RUN TestRequestIDFromContext/returns_ID_when_present_in_context +=== RUN TestRequestIDFromContext/returns_empty_string_when_not_in_context +--- PASS: TestRequestIDFromContext (0.00s) + --- PASS: TestRequestIDFromContext/returns_ID_when_present_in_context (0.00s) + --- PASS: TestRequestIDFromContext/returns_empty_string_when_not_in_context (0.00s) +=== RUN TestTimeoutMiddleware +=== RUN TestTimeoutMiddleware/sets_context_deadline +=== RUN TestTimeoutMiddleware/context_cancelled_after_deadline_exceeded +=== RUN TestTimeoutMiddleware/calls_next_handler +--- PASS: TestTimeoutMiddleware (0.01s) + --- PASS: TestTimeoutMiddleware/sets_context_deadline (0.00s) + --- PASS: TestTimeoutMiddleware/context_cancelled_after_deadline_exceeded (0.01s) + --- PASS: TestTimeoutMiddleware/calls_next_handler (0.00s) +=== RUN TestWriteJSON +=== RUN TestWriteJSON/sets_content_type_to_application/json +=== RUN TestWriteJSON/sets_correct_status_code +=== RUN TestWriteJSON/encodes_struct_as_JSON_body +=== RUN TestWriteJSON/encodes_nil_as_null +=== RUN TestWriteJSON/returns_500_on_encoding_error +2026/02/25 23:53:53 ERROR failed to marshal JSON response error="json: unsupported type: chan int" +--- PASS: TestWriteJSON (0.00s) + --- PASS: TestWriteJSON/sets_content_type_to_application/json (0.00s) + --- PASS: TestWriteJSON/sets_correct_status_code (0.00s) + --- PASS: TestWriteJSON/encodes_struct_as_JSON_body (0.00s) + --- PASS: TestWriteJSON/encodes_nil_as_null (0.00s) + --- PASS: TestWriteJSON/returns_500_on_encoding_error (0.00s) +=== RUN TestWriteError +=== RUN TestWriteError/sets_content_type_to_application/json +=== RUN TestWriteError/sets_correct_status_code +=== RUN TestWriteError/body_has_error_field_with_message +=== RUN TestWriteError/works_with_500_status +--- PASS: TestWriteError (0.00s) + --- PASS: TestWriteError/sets_content_type_to_application/json (0.00s) + --- PASS: TestWriteError/sets_correct_status_code (0.00s) + --- PASS: TestWriteError/body_has_error_field_with_message (0.00s) + --- PASS: TestWriteError/works_with_500_status (0.00s) +=== RUN TestNewRouter +=== RUN TestNewRouter/health_endpoint_returns_200_with_status_ok +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=21.027µs request_id=4a409adc-b846-47b5-a524-bbb1ef293225 +=== RUN TestNewRouter/unknown_route_returns_404_with_JSON_error +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/nonexistent status=404 duration=5.416µs request_id=aff77663-9e7b-42b3-8777-a1d810aacb68 +=== RUN TestNewRouter/CORS_headers_present_on_health_endpoint +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=5.066µs request_id=498e8f04-3504-4cc1-803e-b779e1aca88e +=== RUN TestNewRouter/OPTIONS_returns_204 +=== RUN TestNewRouter/X-Request-ID_header_present_on_health_endpoint +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=13.11µs request_id=b0bf632a-121d-4731-aae5-4042eade7e60 +=== RUN TestNewRouter/X-Request-ID_passthrough_from_client_header +2026/02/25 23:53:53 INFO request completed method=GET path=/api/v1/health status=200 duration=14.91µs request_id=client-trace-id +--- PASS: TestNewRouter (0.00s) + --- PASS: TestNewRouter/health_endpoint_returns_200_with_status_ok (0.00s) + --- PASS: TestNewRouter/unknown_route_returns_404_with_JSON_error (0.00s) + --- PASS: TestNewRouter/CORS_headers_present_on_health_endpoint (0.00s) + --- PASS: TestNewRouter/OPTIONS_returns_204 (0.00s) + --- PASS: TestNewRouter/X-Request-ID_header_present_on_health_endpoint (0.00s) + --- PASS: TestNewRouter/X-Request-ID_passthrough_from_client_header (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/api 0.014s coverage: 100.0% of statements +=== RUN TestCacheKey_Deterministic +--- PASS: TestCacheKey_Deterministic (0.00s) +=== RUN TestCacheKey_DifferentURLs +--- PASS: TestCacheKey_DifferentURLs (0.00s) +=== RUN TestCacheKey_DifferentHashes +--- PASS: TestCacheKey_DifferentHashes (0.00s) +=== RUN TestCacheKey_Format +--- PASS: TestCacheKey_Format (0.00s) +=== RUN TestMockCache_GetReturnsConfiguredResult +--- PASS: TestMockCache_GetReturnsConfiguredResult (0.00s) +=== RUN TestMockCache_GetReturnsConfiguredError +--- PASS: TestMockCache_GetReturnsConfiguredError (0.00s) +=== RUN TestMockCache_SetRecordsCalls +--- PASS: TestMockCache_SetRecordsCalls (0.00s) +=== RUN TestMockCache_SetReturnsConfiguredError +--- PASS: TestMockCache_SetReturnsConfiguredError (0.00s) +=== RUN TestMockCache_GetRecordsCalls +--- PASS: TestMockCache_GetRecordsCalls (0.00s) +=== RUN TestMemoryCache_GetMissReturnsNil +--- PASS: TestMemoryCache_GetMissReturnsNil (0.00s) +=== RUN TestMemoryCache_SetAndGet +--- PASS: TestMemoryCache_SetAndGet (0.00s) +=== RUN TestMemoryCache_DifferentKeys +--- PASS: TestMemoryCache_DifferentKeys (0.00s) +=== RUN TestMemoryCache_Expiration +--- PASS: TestMemoryCache_Expiration (0.03s) +=== RUN TestMemoryCache_TypeSafety +--- PASS: TestMemoryCache_TypeSafety (0.00s) +=== RUN TestMemoryCache_OverwriteKey +--- PASS: TestMemoryCache_OverwriteKey (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/cache 0.035s coverage: 100.0% of statements +=== RUN TestLoad +=== RUN TestLoad/loads_all_fields_from_environment +=== RUN TestLoad/applies_defaults_for_optional_fields_when_unset +=== RUN TestLoad/returns_error_when_ANTHROPIC_API_KEY_is_missing +=== RUN TestLoad/returns_error_when_OPENAI_API_KEY_is_missing +=== RUN TestLoad/lists_all_missing_required_fields_in_error +--- PASS: TestLoad (0.00s) + --- PASS: TestLoad/loads_all_fields_from_environment (0.00s) + --- PASS: TestLoad/applies_defaults_for_optional_fields_when_unset (0.00s) + --- PASS: TestLoad/returns_error_when_ANTHROPIC_API_KEY_is_missing (0.00s) + --- PASS: TestLoad/returns_error_when_OPENAI_API_KEY_is_missing (0.00s) + --- PASS: TestLoad/lists_all_missing_required_fields_in_error (0.00s) +=== RUN TestParseLogLevel +=== RUN TestParseLogLevel/debug +=== RUN TestParseLogLevel/info +=== RUN TestParseLogLevel/warn +=== RUN TestParseLogLevel/error +=== RUN TestParseLogLevel/#00 +=== RUN TestParseLogLevel/verbose +=== RUN TestParseLogLevel/TRACE +--- PASS: TestParseLogLevel (0.00s) + --- PASS: TestParseLogLevel/debug (0.00s) + --- PASS: TestParseLogLevel/info (0.00s) + --- PASS: TestParseLogLevel/warn (0.00s) + --- PASS: TestParseLogLevel/error (0.00s) + --- PASS: TestParseLogLevel/#00 (0.00s) + --- PASS: TestParseLogLevel/verbose (0.00s) + --- PASS: TestParseLogLevel/TRACE (0.00s) +=== RUN TestNewLogger +=== RUN TestNewLogger/produces_valid_JSON_output +=== RUN TestNewLogger/filters_messages_below_configured_level +=== RUN TestNewLogger/NewLogger_writes_to_stderr_and_does_not_panic +--- PASS: TestNewLogger (0.00s) + --- PASS: TestNewLogger/produces_valid_JSON_output (0.00s) + --- PASS: TestNewLogger/filters_messages_below_configured_level (0.00s) + --- PASS: TestNewLogger/NewLogger_writes_to_stderr_and_does_not_panic (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/config 0.002s coverage: 100.0% of statements +=== RUN TestMockEmbeddingClient_ReturnsConfiguredVectors +--- PASS: TestMockEmbeddingClient_ReturnsConfiguredVectors (0.00s) +=== RUN TestMockEmbeddingClient_ReturnsConfiguredError +--- PASS: TestMockEmbeddingClient_ReturnsConfiguredError (0.00s) +=== RUN TestMockEmbeddingClient_RecordsSingleCall +--- PASS: TestMockEmbeddingClient_RecordsSingleCall (0.00s) +=== RUN TestMockEmbeddingClient_RecordsMultipleCalls +--- PASS: TestMockEmbeddingClient_RecordsMultipleCalls (0.00s) +=== RUN TestMockEmbeddingClient_ZeroValueReturnsNil +--- PASS: TestMockEmbeddingClient_ZeroValueReturnsNil (0.00s) +=== RUN TestOpenAIClient_EmptyInputReturnsEmptySlice +--- PASS: TestOpenAIClient_EmptyInputReturnsEmptySlice (0.00s) +=== RUN TestOpenAIClient_SingleTextReturnsSingleVector +--- PASS: TestOpenAIClient_SingleTextReturnsSingleVector (0.00s) +=== RUN TestOpenAIClient_BatchEmbeddingReturnsSameOrder +--- PASS: TestOpenAIClient_BatchEmbeddingReturnsSameOrder (0.00s) +=== RUN TestOpenAIClient_ContextCancellationReturnsError +--- PASS: TestOpenAIClient_ContextCancellationReturnsError (0.00s) +=== RUN TestOpenAIClient_APIErrorReturnsWrappedError +--- PASS: TestOpenAIClient_APIErrorReturnsWrappedError (0.00s) +=== RUN TestOpenAIClient_UnauthorizedReturnsWrappedError +--- PASS: TestOpenAIClient_UnauthorizedReturnsWrappedError (0.00s) +=== RUN TestOpenAIClient_Returns1536DimensionVector +--- PASS: TestOpenAIClient_Returns1536DimensionVector (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/embedding 0.006s coverage: 100.0% of statements +=== RUN TestEstimateTokens +=== RUN TestEstimateTokens/empty_string +=== RUN TestEstimateTokens/single_word +=== RUN TestEstimateTokens/three_words +=== RUN TestEstimateTokens/ten_words +=== RUN TestEstimateTokens/whitespace_only +--- PASS: TestEstimateTokens (0.00s) + --- PASS: TestEstimateTokens/empty_string (0.00s) + --- PASS: TestEstimateTokens/single_word (0.00s) + --- PASS: TestEstimateTokens/three_words (0.00s) + --- PASS: TestEstimateTokens/ten_words (0.00s) + --- PASS: TestEstimateTokens/whitespace_only (0.00s) +=== RUN TestEstimateTokens_Approximation +--- PASS: TestEstimateTokens_Approximation (0.00s) +=== RUN TestChunkContent_EmptyInput +--- PASS: TestChunkContent_EmptyInput (0.00s) +=== RUN TestChunkContent_DefaultOptions +--- PASS: TestChunkContent_DefaultOptions (0.00s) +=== RUN TestChunkContent_SingleChunkShortDocument +--- PASS: TestChunkContent_SingleChunkShortDocument (0.00s) +=== RUN TestChunkContent_SectionsWithinTarget +--- PASS: TestChunkContent_SectionsWithinTarget (0.00s) +=== RUN TestChunkContent_LargeSectionSplitAtParagraphs +--- PASS: TestChunkContent_LargeSectionSplitAtParagraphs (0.00s) +=== RUN TestChunkContent_NoOverlapAcrossSections +--- PASS: TestChunkContent_NoOverlapAcrossSections (0.00s) +=== RUN TestChunkContent_OverlapWithinSection +--- PASS: TestChunkContent_OverlapWithinSection (0.00s) +=== RUN TestChunkContent_SentenceSplitting +--- PASS: TestChunkContent_SentenceSplitting (0.00s) +=== RUN TestChunkContent_UnstructuredFallback +--- PASS: TestChunkContent_UnstructuredFallback (0.00s) +=== RUN TestChunkContent_UnstructuredShortText +--- PASS: TestChunkContent_UnstructuredShortText (0.00s) +=== RUN TestChunkContent_SequentialIndexes +--- PASS: TestChunkContent_SequentialIndexes (0.00s) +=== RUN TestChunkContent_SectionHeadingOnly +--- PASS: TestChunkContent_SectionHeadingOnly (0.00s) +=== RUN TestIsPrivacyPolicy_RealPolicyPage +--- PASS: TestIsPrivacyPolicy_RealPolicyPage (0.00s) +=== RUN TestIsPrivacyPolicy_NonPolicyPage +--- PASS: TestIsPrivacyPolicy_NonPolicyPage (0.00s) +=== RUN TestIsPrivacyPolicy_HeadingSignalDominates +--- PASS: TestIsPrivacyPolicy_HeadingSignalDominates (0.00s) +=== RUN TestIsPrivacyPolicy_KeywordDensityWithoutHeadings +--- PASS: TestIsPrivacyPolicy_KeywordDensityWithoutHeadings (0.00s) +=== RUN TestIsPrivacyPolicy_CaseInsensitivity +--- PASS: TestIsPrivacyPolicy_CaseInsensitivity (0.00s) +=== RUN TestIsPrivacyPolicy_EmptyContent +--- PASS: TestIsPrivacyPolicy_EmptyContent (0.00s) +=== RUN TestIsPrivacyPolicy_NilContent +--- PASS: TestIsPrivacyPolicy_NilContent (0.00s) +=== RUN TestIsPrivacyPolicy_ShortContent +--- PASS: TestIsPrivacyPolicy_ShortContent (0.00s) +=== RUN TestIsPrivacyPolicy_TermsOfService +--- PASS: TestIsPrivacyPolicy_TermsOfService (0.00s) +=== RUN TestIsPrivacyPolicy_TermsAndConditions +--- PASS: TestIsPrivacyPolicy_TermsAndConditions (0.00s) +=== RUN TestIsPrivacyPolicy_ConfidenceInRange +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/empty +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/short +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/real_policy +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/news_article +=== RUN TestIsPrivacyPolicy_ConfidenceInRange/high_density_no_heading +--- PASS: TestIsPrivacyPolicy_ConfidenceInRange (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/empty (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/short (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/real_policy (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/news_article (0.00s) + --- PASS: TestIsPrivacyPolicy_ConfidenceInRange/high_density_no_heading (0.00s) +=== RUN TestIsPrivacyPolicy_MultipleHeadings +--- PASS: TestIsPrivacyPolicy_MultipleHeadings (0.00s) +=== RUN TestDefaultParseOptions +--- PASS: TestDefaultParseOptions (0.00s) +=== RUN TestParse_CleanTextExtraction +--- PASS: TestParse_CleanTextExtraction (0.00s) +=== RUN TestParse_StripsUnwantedElements +--- PASS: TestParse_StripsUnwantedElements (0.00s) +=== RUN TestParse_StripsHiddenContent +=== RUN TestParse_StripsHiddenContent/display:none +=== RUN TestParse_StripsHiddenContent/display:none_no_space +=== RUN TestParse_StripsHiddenContent/visibility:hidden +=== RUN TestParse_StripsHiddenContent/opacity:0 +=== RUN TestParse_StripsHiddenContent/opacity:0_no_space +=== RUN TestParse_StripsHiddenContent/font-size:0 +=== RUN TestParse_StripsHiddenContent/font-size:0_no_space +=== RUN TestParse_StripsHiddenContent/hidden_attribute +=== RUN TestParse_StripsHiddenContent/aria-hidden=true +=== RUN TestParse_StripsHiddenContent/off-screen_positioned +=== RUN TestParse_StripsHiddenContent/off-screen_top +--- PASS: TestParse_StripsHiddenContent (0.00s) + --- PASS: TestParse_StripsHiddenContent/display:none (0.00s) + --- PASS: TestParse_StripsHiddenContent/display:none_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/visibility:hidden (0.00s) + --- PASS: TestParse_StripsHiddenContent/opacity:0 (0.00s) + --- PASS: TestParse_StripsHiddenContent/opacity:0_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/font-size:0 (0.00s) + --- PASS: TestParse_StripsHiddenContent/font-size:0_no_space (0.00s) + --- PASS: TestParse_StripsHiddenContent/hidden_attribute (0.00s) + --- PASS: TestParse_StripsHiddenContent/aria-hidden=true (0.00s) + --- PASS: TestParse_StripsHiddenContent/off-screen_positioned (0.00s) + --- PASS: TestParse_StripsHiddenContent/off-screen_top (0.00s) +=== RUN TestParse_PreservesHiddenContent +--- PASS: TestParse_PreservesHiddenContent (0.00s) +=== RUN TestParse_SectionHeadings +--- PASS: TestParse_SectionHeadings (0.00s) +=== RUN TestParse_HeadingHierarchy +--- PASS: TestParse_HeadingHierarchy (0.00s) +=== RUN TestParse_Breadcrumb +--- PASS: TestParse_Breadcrumb (0.00s) +=== RUN TestParse_CharacterOffsets +--- PASS: TestParse_CharacterOffsets (0.00s) +=== RUN TestParse_SemanticContainers +--- PASS: TestParse_SemanticContainers (0.00s) +=== RUN TestParse_UnstructuredHTML +--- PASS: TestParse_UnstructuredHTML (0.00s) +=== RUN TestParse_EmptyAndMalformed +=== RUN TestParse_EmptyAndMalformed/empty_string +=== RUN TestParse_EmptyAndMalformed/whitespace_only +=== RUN TestParse_EmptyAndMalformed/malformed_HTML +=== RUN TestParse_EmptyAndMalformed/plain_text +--- PASS: TestParse_EmptyAndMalformed (0.00s) + --- PASS: TestParse_EmptyAndMalformed/empty_string (0.00s) + --- PASS: TestParse_EmptyAndMalformed/whitespace_only (0.00s) + --- PASS: TestParse_EmptyAndMalformed/malformed_HTML (0.00s) + --- PASS: TestParse_EmptyAndMalformed/plain_text (0.00s) +=== RUN TestParse_PlainTextContent +--- PASS: TestParse_PlainTextContent (0.00s) +=== RUN TestParse_AccurateCounts +--- PASS: TestParse_AccurateCounts (0.00s) +=== RUN TestParse_HTMLComments +--- PASS: TestParse_HTMLComments (0.00s) +PASS +coverage: 94.4% of statements +ok github.com/parth/smolterms/backend/internal/extractor 0.003s coverage: 94.4% of statements +=== RUN TestAnthropicClient_SuccessfulResponse +--- PASS: TestAnthropicClient_SuccessfulResponse (0.00s) +=== RUN TestAnthropicClient_RequestFormat +--- PASS: TestAnthropicClient_RequestFormat (0.00s) +=== RUN TestAnthropicClient_RateLimitError +--- PASS: TestAnthropicClient_RateLimitError (0.00s) +=== RUN TestAnthropicClient_APIError +--- PASS: TestAnthropicClient_APIError (0.00s) +=== RUN TestAnthropicClient_ContextCancellation +--- PASS: TestAnthropicClient_ContextCancellation (0.00s) +=== RUN TestAnthropicClient_EmptyResponseReturnsError +--- PASS: TestAnthropicClient_EmptyResponseReturnsError (0.00s) +=== RUN TestAnthropicClient_LogsCallDetails +--- PASS: TestAnthropicClient_LogsCallDetails (0.00s) +=== RUN TestMockLLMClient_ReturnsConfiguredResponse +--- PASS: TestMockLLMClient_ReturnsConfiguredResponse (0.00s) +=== RUN TestMockLLMClient_ReturnsConfiguredError +--- PASS: TestMockLLMClient_ReturnsConfiguredError (0.00s) +=== RUN TestMockLLMClient_RecordsCalls +--- PASS: TestMockLLMClient_RecordsCalls (0.00s) +=== RUN TestMockLLMClient_MultipleCalls +--- PASS: TestMockLLMClient_MultipleCalls (0.00s) +=== RUN TestSystemPrompt_Content +--- PASS: TestSystemPrompt_Content (0.00s) +=== RUN TestBuildAnalysisPrompt_ContainsURL +--- PASS: TestBuildAnalysisPrompt_ContainsURL (0.00s) +=== RUN TestBuildAnalysisPrompt_FormatsChunks +--- PASS: TestBuildAnalysisPrompt_FormatsChunks (0.00s) +=== RUN TestBuildAnalysisPrompt_IncludesJSONStructure +--- PASS: TestBuildAnalysisPrompt_IncludesJSONStructure (0.00s) +=== RUN TestBuildAnalysisPrompt_IncludesScoringGuide +--- PASS: TestBuildAnalysisPrompt_IncludesScoringGuide (0.00s) +=== RUN TestBuildAnalysisPrompt_EmptyChunks +--- PASS: TestBuildAnalysisPrompt_EmptyChunks (0.00s) +=== RUN TestBuildAnalysisPrompt_ChunkIndexIncluded +--- PASS: TestBuildAnalysisPrompt_ChunkIndexIncluded (0.00s) +PASS +coverage: 100.0% of statements +ok github.com/parth/smolterms/backend/internal/llm 0.007s coverage: 100.0% of statements +=== RUN TestStore_EmbedsAndUpsertsCorrectly +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=3 embed_latency=459ns upsert_latency=276ns +--- PASS: TestStore_EmbedsAndUpsertsCorrectly (0.00s) +=== RUN TestStore_ConvertChunksMetadata +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=2 embed_latency=213ns upsert_latency=232ns +--- PASS: TestStore_ConvertChunksMetadata (0.00s) +=== RUN TestStore_AssignsVectors +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=2 embed_latency=111ns upsert_latency=209ns +--- PASS: TestStore_AssignsVectors (0.00s) +=== RUN TestStore_EmbedError +--- PASS: TestStore_EmbedError (0.00s) +=== RUN TestStore_UpsertError +--- PASS: TestStore_UpsertError (0.00s) +=== RUN TestRetrieve_EmbedsAndSearches +2026/02/25 23:53:53 INFO retrieved chunks operation=retrieve query="privacy data collection" result_count=1 embed_latency=156ns search_latency=238ns +--- PASS: TestRetrieve_EmbedsAndSearches (0.00s) +=== RUN TestRetrieve_DeduplicatesByText +2026/02/25 23:53:53 INFO retrieved chunks operation=retrieve query=query result_count=2 embed_latency=143ns search_latency=74ns +--- PASS: TestRetrieve_DeduplicatesByText (0.00s) +=== RUN TestRetrieve_EmbedError +--- PASS: TestRetrieve_EmbedError (0.00s) +=== RUN TestRetrieve_SearchError +--- PASS: TestRetrieve_SearchError (0.00s) +=== RUN TestStore_ContextCancellation +--- PASS: TestStore_ContextCancellation (0.00s) +=== RUN TestRetrieve_ContextCancellation +--- PASS: TestRetrieve_ContextCancellation (0.00s) +=== RUN TestStore_LogsOperationFields +--- PASS: TestStore_LogsOperationFields (0.00s) +=== RUN TestRetrieve_LogsOperationFields +--- PASS: TestRetrieve_LogsOperationFields (0.00s) +=== RUN TestNewPipeline_NilLoggerDefaultsToSlogDefault +2026/02/25 23:53:53 INFO stored chunks operation=store chunk_count=1 embed_latency=119ns upsert_latency=40ns +--- PASS: TestNewPipeline_NilLoggerDefaultsToSlogDefault (0.00s) +PASS +coverage: 95.9% of statements +ok github.com/parth/smolterms/backend/internal/rag 0.002s coverage: 95.9% of statements +? github.com/parth/smolterms/backend/internal/types [no test files] +=== RUN TestNewQdrantStore_InvalidURL +--- PASS: TestNewQdrantStore_InvalidURL (0.00s) +=== RUN TestQdrantStore_Upsert_Empty +--- PASS: TestQdrantStore_Upsert_Empty (0.00s) +=== RUN TestQdrantStore_Upsert_FormatsPointsCorrectly +2026/02/25 23:53:53 INFO upserted chunks collection=policies count=1 +--- PASS: TestQdrantStore_Upsert_FormatsPointsCorrectly (0.00s) +=== RUN TestQdrantStore_Upsert_ReturnsError +--- PASS: TestQdrantStore_Upsert_ReturnsError (0.00s) +=== RUN TestQdrantStore_Search_ReturnsOrderedResults +2026/02/25 23:53:53 INFO search complete collection=col1 results=2 latency_ms=0 +--- PASS: TestQdrantStore_Search_ReturnsOrderedResults (0.00s) +=== RUN TestQdrantStore_Search_ReconstructsChunks +2026/02/25 23:53:53 INFO search complete collection=col1 results=1 latency_ms=0 +--- PASS: TestQdrantStore_Search_ReconstructsChunks (0.00s) +=== RUN TestQdrantStore_Search_ReturnsError +--- PASS: TestQdrantStore_Search_ReturnsError (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CreatesIfNotExists +2026/02/25 23:53:53 INFO creating qdrant collection collection=new-col +2026/02/25 23:53:53 INFO upserted chunks collection=new-col count=1 +--- PASS: TestQdrantStore_EnsureCollection_CreatesIfNotExists (0.00s) +=== RUN TestQdrantStore_EnsureCollection_SkipsIfExists +2026/02/25 23:53:53 INFO upserted chunks collection=existing-col count=1 +--- PASS: TestQdrantStore_EnsureCollection_SkipsIfExists (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CachesResult +2026/02/25 23:53:53 INFO upserted chunks collection=col1 count=1 +2026/02/25 23:53:53 INFO upserted chunks collection=col1 count=1 +--- PASS: TestQdrantStore_EnsureCollection_CachesResult (0.00s) +=== RUN TestQdrantStore_ConnectionFailure +--- PASS: TestQdrantStore_ConnectionFailure (0.00s) +=== RUN TestQdrantStore_ContextCancellation_Upsert +--- PASS: TestQdrantStore_ContextCancellation_Upsert (0.00s) +=== RUN TestQdrantStore_ContextCancellation_Search +--- PASS: TestQdrantStore_ContextCancellation_Search (0.00s) +=== RUN TestQdrantStore_Search_QueryParams +2026/02/25 23:53:53 INFO search complete collection=test-col results=0 latency_ms=0 +--- PASS: TestQdrantStore_Search_QueryParams (0.00s) +=== RUN TestChunkUUID_Deterministic +--- PASS: TestChunkUUID_Deterministic (0.00s) +=== RUN TestNewQdrantStore_ValidURL +--- PASS: TestNewQdrantStore_ValidURL (0.00s) +=== RUN TestQdrantStore_EnsureCollection_CreateCollectionError +2026/02/25 23:53:53 INFO creating qdrant collection collection=col1 +--- PASS: TestQdrantStore_EnsureCollection_CreateCollectionError (0.00s) +=== RUN TestQdrantStore_EnsureCollection_FieldIndexError +2026/02/25 23:53:53 INFO creating qdrant collection collection=col1 +--- PASS: TestQdrantStore_EnsureCollection_FieldIndexError (0.00s) +=== RUN TestQdrantStore_Upsert_PrefersChunkID +2026/02/25 23:53:53 INFO upserted chunks collection=col count=2 +--- PASS: TestQdrantStore_Upsert_PrefersChunkID (0.00s) +=== RUN TestQdrantStore_Search_PopulatesChunkID +2026/02/25 23:53:53 INFO search complete collection=col1 results=1 latency_ms=0 +--- PASS: TestQdrantStore_Search_PopulatesChunkID (0.00s) +=== RUN TestQdrantStore_Upsert_LogsEntry +--- PASS: TestQdrantStore_Upsert_LogsEntry (0.00s) +=== RUN TestQdrantStore_Search_LogsEntry +--- PASS: TestQdrantStore_Search_LogsEntry (0.00s) +=== RUN TestChunkFields +--- PASS: TestChunkFields (0.00s) +=== RUN TestVectorStoreInterfaceSatisfied +--- PASS: TestVectorStoreInterfaceSatisfied (0.00s) +=== RUN TestMockUpsert_ReturnsNilByDefault +--- PASS: TestMockUpsert_ReturnsNilByDefault (0.00s) +=== RUN TestMockUpsert_ReturnsConfiguredError +--- PASS: TestMockUpsert_ReturnsConfiguredError (0.00s) +=== RUN TestMockUpsert_RecordsCalls +--- PASS: TestMockUpsert_RecordsCalls (0.00s) +=== RUN TestMockSearch_ReturnsConfiguredChunks +--- PASS: TestMockSearch_ReturnsConfiguredChunks (0.00s) +=== RUN TestMockSearch_ReturnsNilAndEmptyByDefault +--- PASS: TestMockSearch_ReturnsNilAndEmptyByDefault (0.00s) +=== RUN TestMockSearch_ReturnsConfiguredError +--- PASS: TestMockSearch_ReturnsConfiguredError (0.00s) +=== RUN TestMockSearch_RecordsCalls +--- PASS: TestMockSearch_RecordsCalls (0.00s) +=== RUN TestMockRecordsMultipleCalls +--- PASS: TestMockRecordsMultipleCalls (0.00s) +PASS +coverage: 97.6% of statements +ok github.com/parth/smolterms/backend/internal/vectorstore 0.003s coverage: 97.6% of statements diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md new file mode 100644 index 0000000..70937c6 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/plan.md @@ -0,0 +1,63 @@ +# Plan: Request ID and Timeout Middleware + +## Test Strategy + +### Request ID Middleware Tests +1. **Generates UUID when no header present** - Request without X-Request-ID -> response has X-Request-ID header with valid UUID format +2. **Uses existing header value** - Request with X-Request-ID: "test-id-123" -> response X-Request-ID = "test-id-123" +3. **Injects request ID into context** - Downstream handler calls `RequestIDFromContext(r.Context())` -> returns the ID +4. **Generated ID is valid UUID v4 format** - Verify format matches `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` +5. **Calls next handler** - Verify the downstream handler is invoked + +### Timeout Middleware Tests +6. **Sets context deadline** - After middleware, `r.Context().Deadline()` returns true with deadline ~60s from now +7. **Context cancelled after deadline** - With very short timeout (1ms), slow handler sees `context.DeadlineExceeded` +8. **Calls next handler** - Verify the downstream handler is invoked +9. **Cancel function called** - Verify no goroutine leak (cancel is deferred) + +### Logging Middleware Update Tests +10. **Includes request_id in log output** - When request_id is in context, log entry contains `request_id=` +11. **Handles missing request_id** - When no request_id in context, log entry does NOT contain request_id field (or contains empty) + +### RequestIDFromContext Tests +12. **Returns ID when present** - Context with request ID -> returns the ID string +13. **Returns empty string when absent** - Empty context -> returns "" + +### Router Integration Tests +14. **X-Request-ID header present on health** - Health endpoint response includes X-Request-ID +15. **Existing router tests still pass** - All 4 existing TestNewRouter subtests pass + +## Implementation Plan + +### Step 1: Write tests (RED phase) +- Add tests to `middleware_test.go` for RequestIDMiddleware, TimeoutMiddleware, RequestIDFromContext, and updated LoggingMiddleware +- Add integration test to `router_test.go` for X-Request-ID header presence + +### Step 2: Implement middleware (GREEN phase) +1. Add context key type and constant to `middleware.go` +2. Implement `RequestIDMiddleware` in `middleware.go` +3. Implement `RequestIDFromContext` helper in `middleware.go` +4. Implement `TimeoutMiddleware` in `middleware.go` +5. Update `LoggingMiddleware` to include request_id +6. Update `NewRouter` to wire new middleware + +### Step 3: Refactor +- Review for consistency with existing patterns +- Ensure no unnecessary complexity + +## Implementation Checklist +- [ ] Tests written for RequestIDMiddleware +- [ ] Tests written for TimeoutMiddleware +- [ ] Tests written for RequestIDFromContext +- [ ] Tests written for LoggingMiddleware update +- [ ] Tests written for router integration +- [ ] All tests fail (RED confirmed) +- [ ] Context key type and RequestIDFromContext implemented +- [ ] RequestIDMiddleware implemented +- [ ] TimeoutMiddleware implemented +- [ ] LoggingMiddleware updated +- [ ] NewRouter updated +- [ ] All tests pass (GREEN confirmed) +- [ ] Refactoring complete +- [ ] Existing tests still pass +- [ ] Build succeeds diff --git a/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md new file mode 100644 index 0000000..ea33b0e --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/request-id-and-timeout-middleware/progress.md @@ -0,0 +1,37 @@ +# Progress: Request ID and Timeout Middleware + +## Setup +- [x] Documentation directory created +- [x] Instruction files discovered (none found besides CLAUDE.md) +- [x] Existing code explored and patterns documented + +## Explore +- [x] Design doc section 6 read (error handling, 504 timeout, context propagation) +- [x] Existing middleware.go analyzed (CORSMiddleware, LoggingMiddleware, statusRecorder) +- [x] Existing router.go analyzed (NewRouter with middleware chain) +- [x] Existing tests analyzed (unit + integration patterns) +- [x] No existing context key patterns in codebase + +## Plan +- [x] Test strategy designed +- [x] Implementation plan created + +## Code +- [x] TDD RED phase - tests written and confirmed failing (12 compilation errors) +- [x] TDD GREEN phase - implementation written and all 27 API tests passing +- [x] Refactor phase - code reviewed, consistent with existing patterns +- [x] Validation - all backend tests pass, build succeeds, API package at 100% coverage + +### TDD Cycles +1. RED: Added 13 new test cases across 4 test functions, confirmed compilation failure +2. GREEN: Implemented contextKey, RequestIDMiddleware, RequestIDFromContext, TimeoutMiddleware, updated LoggingMiddleware, updated NewRouter +3. REFACTOR: Reviewed naming, documentation, error handling, code organization - all consistent + +### Design Decisions +- Used `crypto/rand` for UUID v4 generation (no new dependency) +- Unexported `contextKey` type prevents context key collisions +- `generateUUID` is unexported helper (internal implementation detail) +- LoggingMiddleware conditionally includes request_id only when present + +## Commit +- [ ] Changes committed diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index e367f17..88ae852 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -1,11 +1,20 @@ package api import ( + "context" + "crypto/rand" + "fmt" "log/slog" "net/http" "time" ) +// contextKey is an unexported type for context keys in this package, +// preventing collisions with keys defined in other packages. +type contextKey string + +const requestIDKey contextKey = "request_id" + // statusRecorder wraps http.ResponseWriter to capture the response status code. type statusRecorder struct { http.ResponseWriter @@ -46,6 +55,7 @@ func CORSMiddleware(next http.Handler) http.Handler { } // LoggingMiddleware returns middleware that logs request method, path, status code, and duration. +// If a request ID is present in the context, it is included in the log entry. func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { if logger == nil { logger = slog.Default() @@ -57,12 +67,71 @@ func LoggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler { next.ServeHTTP(sr, r) - logger.Info("request completed", + attrs := []any{ "method", r.Method, "path", r.URL.Path, "status", sr.statusCode, "duration", time.Since(start), - ) + } + + if id := RequestIDFromContext(r.Context()); id != "" { + attrs = append(attrs, "request_id", id) + } + + logger.Info("request completed", attrs...) + }) + } +} + +// RequestIDMiddleware generates or propagates a unique request ID for each request. +// If the incoming request has an X-Request-ID header, that value is used. +// Otherwise, a new UUID v4 is generated. +// The request ID is set on the X-Request-ID response header and injected into the request context. +func RequestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + id := r.Header.Get("X-Request-ID") + if id == "" { + id = generateUUID() + } + + w.Header().Set("X-Request-ID", id) + + ctx := context.WithValue(r.Context(), requestIDKey, id) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// RequestIDFromContext extracts the request ID from the context. +// Returns an empty string if no request ID is present. +func RequestIDFromContext(ctx context.Context) string { + id, _ := ctx.Value(requestIDKey).(string) + return id +} + +// TimeoutMiddleware returns middleware that wraps the request context with a deadline. +// If the handler does not complete within the given duration, the context is cancelled +// with context.DeadlineExceeded, allowing downstream handlers to detect the timeout. +func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), timeout) + defer cancel() + + next.ServeHTTP(w, r.WithContext(ctx)) }) } } + +// generateUUID generates a UUID v4 string using crypto/rand. +func generateUUID() string { + var uuid [16]byte + _, _ = rand.Read(uuid[:]) + + // Set version to 4 (random) + uuid[6] = (uuid[6] & 0x0f) | 0x40 + // Set variant to RFC 4122 + uuid[8] = (uuid[8] & 0x3f) | 0x80 + + return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", + uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]) +} diff --git a/backend/internal/api/middleware_test.go b/backend/internal/api/middleware_test.go index 53da327..e081336 100644 --- a/backend/internal/api/middleware_test.go +++ b/backend/internal/api/middleware_test.go @@ -2,11 +2,14 @@ package api import ( "bytes" + "context" "log/slog" "net/http" "net/http/httptest" + "regexp" "strings" "testing" + "time" ) func TestCORSMiddleware(t *testing.T) { @@ -143,4 +146,212 @@ func TestLoggingMiddleware(t *testing.T) { t.Errorf("log output missing status=200\ngot: %s", output) } }) + + t.Run("includes request_id when present in context", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := LoggingMiddleware(logger)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test", nil) + + // Inject request ID into context (simulating RequestIDMiddleware upstream) + ctx := context.WithValue(r.Context(), requestIDKey, "test-request-id-456") + r = r.WithContext(ctx) + + handler.ServeHTTP(w, r) + + output := buf.String() + if !strings.Contains(output, "request_id=test-request-id-456") { + t.Errorf("log output missing request_id=test-request-id-456\ngot: %s", output) + } + }) + + t.Run("omits request_id when not in context", func(t *testing.T) { + var buf bytes.Buffer + logger := slog.New(slog.NewTextHandler(&buf, nil)) + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := LoggingMiddleware(logger)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/test", nil) + + handler.ServeHTTP(w, r) + + output := buf.String() + if strings.Contains(output, "request_id") { + t.Errorf("log output should not contain request_id when not in context\ngot: %s", output) + } + }) +} + +func TestRequestIDMiddleware(t *testing.T) { + t.Run("generates UUID when no X-Request-ID header present", func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + got := w.Header().Get("X-Request-ID") + if got == "" { + t.Fatal("X-Request-ID header not set") + } + + // UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx + uuidPattern := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + if !uuidPattern.MatchString(got) { + t.Errorf("X-Request-ID = %q, does not match UUID v4 pattern", got) + } + }) + + t.Run("uses existing X-Request-ID header value", func(t *testing.T) { + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + r.Header.Set("X-Request-ID", "existing-id-123") + + handler.ServeHTTP(w, r) + + got := w.Header().Get("X-Request-ID") + if got != "existing-id-123" { + t.Errorf("X-Request-ID = %q, want %q", got, "existing-id-123") + } + }) + + t.Run("injects request ID into context", func(t *testing.T) { + var ctxID string + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctxID = RequestIDFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if ctxID == "" { + t.Fatal("RequestIDFromContext returned empty string") + } + + // The context ID should match the response header + headerID := w.Header().Get("X-Request-ID") + if ctxID != headerID { + t.Errorf("context ID = %q, header ID = %q, want them to match", ctxID, headerID) + } + }) + + t.Run("calls next handler", func(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := RequestIDMiddleware(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if !called { + t.Error("next handler was not called") + } + }) +} + +func TestRequestIDFromContext(t *testing.T) { + t.Run("returns ID when present in context", func(t *testing.T) { + ctx := context.WithValue(context.Background(), requestIDKey, "test-id-789") + got := RequestIDFromContext(ctx) + if got != "test-id-789" { + t.Errorf("RequestIDFromContext = %q, want %q", got, "test-id-789") + } + }) + + t.Run("returns empty string when not in context", func(t *testing.T) { + got := RequestIDFromContext(context.Background()) + if got != "" { + t.Errorf("RequestIDFromContext = %q, want empty string", got) + } + }) +} + +func TestTimeoutMiddleware(t *testing.T) { + t.Run("sets context deadline", func(t *testing.T) { + timeout := 60 * time.Second + var hasDeadline bool + var deadline time.Time + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deadline, hasDeadline = r.Context().Deadline() + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(timeout)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + before := time.Now() + handler.ServeHTTP(w, r) + + if !hasDeadline { + t.Fatal("context does not have a deadline") + } + + // Deadline should be approximately 60 seconds from now + expectedDeadline := before.Add(timeout) + diff := deadline.Sub(expectedDeadline) + if diff < -time.Second || diff > time.Second { + t.Errorf("deadline diff from expected = %v, want within ±1s", diff) + } + }) + + t.Run("context cancelled after deadline exceeded", func(t *testing.T) { + timeout := time.Millisecond + var ctxErr error + + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Wait for the deadline to expire + time.Sleep(10 * time.Millisecond) + ctxErr = r.Context().Err() + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(timeout)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if ctxErr != context.DeadlineExceeded { + t.Errorf("context error = %v, want %v", ctxErr, context.DeadlineExceeded) + } + }) + + t.Run("calls next handler", func(t *testing.T) { + called := false + next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + called = true + w.WriteHeader(http.StatusOK) + }) + handler := TimeoutMiddleware(time.Second)(next) + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/", nil) + + handler.ServeHTTP(w, r) + + if !called { + t.Error("next handler was not called") + } + }) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 9cd149c..8abb5ea 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -3,12 +3,17 @@ package api import ( "log/slog" "net/http" + "time" "github.com/parth/smolterms/backend/internal/config" ) +// defaultRequestTimeout is the default deadline for request processing. +const defaultRequestTimeout = 60 * time.Second + // NewRouter creates and returns a fully configured HTTP handler with -// registered routes and middleware (CORS and request logging). +// registered routes and middleware. +// Middleware order (outermost to innermost): CORS → Request ID → Timeout → Logging → routes. // If logger is nil, the default slog logger is used. // If cfg is nil, the health handler uses zero-value config defaults. func NewRouter(logger *slog.Logger, cfg *config.Config) http.Handler { @@ -23,6 +28,8 @@ func NewRouter(logger *slog.Logger, cfg *config.Config) http.Handler { var handler http.Handler = mux handler = LoggingMiddleware(logger)(handler) + handler = TimeoutMiddleware(defaultRequestTimeout)(handler) + handler = RequestIDMiddleware(handler) handler = CORSMiddleware(handler) return handler diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 6d9ff2c..8eb2f99 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -83,4 +83,32 @@ func TestNewRouter(t *testing.T) { t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusNoContent) } }) + + t.Run("X-Request-ID header present on health endpoint", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/health") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + got := resp.Header.Get("X-Request-ID") + if got == "" { + t.Error("X-Request-ID header not set on response") + } + }) + + t.Run("X-Request-ID passthrough from client header", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodGet, server.URL+"/api/v1/health", nil) + req.Header.Set("X-Request-ID", "client-trace-id") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + got := resp.Header.Get("X-Request-ID") + if got != "client-trace-id" { + t.Errorf("X-Request-ID = %q, want %q", got, "client-trace-id") + } + }) } From e467683425a210cac6f3ecad847edbf81ac2619a Mon Sep 17 00:00:00 2001 From: Parth576 Date: Thu, 26 Feb 2026 00:06:30 -0500 Subject: [PATCH 2/3] feat(api): add analyze handler and update health endpoint with Qdrant check Add POST /api/v1/analyze endpoint that validates JSON input (url, html fields), invokes the analysis pipeline, and maps errors to appropriate HTTP status codes (400 validation, 504 timeout, 502 upstream failure). Introduce PipelineRunner interface for testability, allowing the handler to be tested with a mock analyzer without constructing the full dependency chain. Update NewHealthHandler to accept an optional Qdrant health check function that reports 'connected' or 'unavailable' status, falling back to showing the configured URL when no check is provided. Update NewRouter signature to accept pipeline and qdrant check dependencies, register the new analyze route, and update main.go accordingly (passing nil for now until full pipeline wiring). Assisted by the code-assist SOP --- .../context.md | 31 ++ .../plan.md | 54 +++ .../progress.md | 19 ++ backend/cmd/server/main.go | 6 +- backend/internal/api/handler.go | 88 ++++- backend/internal/api/handler_test.go | 314 +++++++++++++++++- backend/internal/api/router.go | 10 +- backend/internal/api/router_test.go | 86 ++++- 8 files changed, 593 insertions(+), 15 deletions(-) create mode 100644 .agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md create mode 100644 .agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md create mode 100644 .agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md new file mode 100644 index 0000000..e428620 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/context.md @@ -0,0 +1,31 @@ +# Context: Task 02 - Analyze Handler and Routing + +## Project Structure +- Go 1.22+ with stdlib `net/http` method-based routing +- Handler factory pattern: `NewXxxHandler(deps) http.HandlerFunc` +- Response helpers: `WriteJSON[T]` and `WriteError` in `api/response.go` +- Response types in `backend/internal/types/response.go` +- White-box testing with `httptest.NewRecorder` + `httptest.NewRequest` +- Middleware stack: CORS -> RequestID -> Timeout -> Logging -> routes + +## Requirements +1. Create `NewAnalyzeHandler` factory in `handler.go` - accepts `*analyzer.Analyzer` and `*slog.Logger` +2. Decode JSON body into `analyzer.AnalysisRequest`, validate url/html non-empty +3. Error mapping: malformed JSON -> 400, empty fields -> 400, deadline exceeded -> 504, other errors -> 502 +4. Update `NewHealthHandler` to accept optional `func(ctx) error` for Qdrant health check +5. Update `NewRouter` to accept analyzer dependency and register `POST /api/v1/analyze` +6. Update `main.go` to pass new dependencies + +## Existing Patterns +- Factory functions return `http.HandlerFunc` closures capturing dependencies +- Nil-safe: handlers default gracefully on nil deps +- `WriteJSON` / `WriteError` for all responses +- `got/want` naming in test assertions +- Table-driven subtests with `t.Run` +- Handler tests use `httptest.NewRecorder`, router tests use `httptest.NewServer` + +## Dependencies +- `analyzer.Analyzer` has method: `Analyze(ctx, AnalysisRequest) (*AnalysisResult, error)` +- `analyzer.AnalysisRequest` has `URL string` and `HTML string` with json tags +- `analyzer.AnalysisResult` is the full response type (already has json tags matching API contract) +- For testing: need to mock `*analyzer.Analyzer` - cannot construct directly (panics on nil rag/llm) diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md new file mode 100644 index 0000000..493a722 --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/plan.md @@ -0,0 +1,54 @@ +# Plan: Task 02 - Analyze Handler and Routing + +## Test Strategy + +### Mocking Approach +The `Analyzer` struct uses concrete dependencies (rag.Pipeline, llm.LLMClient) that panic on nil. For handler tests, we need to create an interface that `Analyze` satisfies, or use a thin wrapper. The cleanest approach: define a local `pipelineRunner` interface in the handler with `Analyze(ctx, AnalysisRequest) (*AnalysisResult, error)` that `*analyzer.Analyzer` satisfies. This allows easy mocking in tests. + +### Analyze Handler Tests (handler_test.go) +1. **Valid request returns 200 with AnalysisResult** - POST with valid url+html, mock returns result +2. **Missing URL returns 400** - POST with empty url, expects error message about url +3. **Missing HTML returns 400** - POST with empty html, expects error message about html +4. **Malformed JSON returns 400** - POST with invalid JSON body, expects error about invalid JSON +5. **Empty body returns 400** - POST with no body +6. **Analyzer timeout returns 504** - Mock returns context.DeadlineExceeded, expects 504 +7. **Analyzer error returns 502** - Mock returns generic error, expects 502 +8. **Non-policy result returns 200** - Mock returns result with RiskNotPolicy, expects 200 +9. **Content-Type is application/json** - All responses should have correct content type + +### Health Handler Tests (handler_test.go) +10. **Qdrant connected** - Health check func returns nil, expects "connected" +11. **Qdrant unavailable** - Health check func returns error, expects "unavailable" +12. **Nil health check falls back to URL** - No func provided, shows URL (backward compat) + +### Router Tests (router_test.go) +13. **POST /api/v1/analyze routes to handler** - Integration test with mock analyzer +14. **GET /api/v1/analyze returns 405** - Wrong method +15. **OPTIONS /api/v1/analyze returns 204 with CORS** - Preflight support + +## Implementation Plan + +### Step 1: Define pipeline interface for testability +Add a `PipelineRunner` interface in handler.go: +``` +type PipelineRunner interface { + Analyze(ctx context.Context, req analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) +} +``` +`*analyzer.Analyzer` implicitly satisfies this. + +### Step 2: Implement NewAnalyzeHandler +Factory: `NewAnalyzeHandler(pipeline PipelineRunner, logger *slog.Logger) http.HandlerFunc` +- Decode JSON, validate, call Analyze, map errors to status codes + +### Step 3: Update NewHealthHandler +Change signature: `NewHealthHandler(cfg *config.Config, qdrantCheck func(ctx context.Context) error) http.HandlerFunc` +- If qdrantCheck != nil: call it, report "connected" or "unavailable" +- If qdrantCheck == nil: fall back to current behavior (show URL) + +### Step 4: Update NewRouter +Change signature: add PipelineRunner parameter +Register `POST /api/v1/analyze` route + +### Step 5: Update main.go +Pass nil for analyzer and qdrant check for now (full wiring deferred to later steps) diff --git a/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md new file mode 100644 index 0000000..1c5798b --- /dev/null +++ b/.agents/scratchpad/2026-02-15-smolterms/task-02-analyze-handler-and-routing/progress.md @@ -0,0 +1,19 @@ +# Progress: Task 02 - Analyze Handler and Routing + +## Setup +- [x] Created documentation directory +- [x] Discovered instruction files (CLAUDE.md) +- [x] Explored existing codebase patterns +- [x] Created context.md + +## Implementation Checklist +- [ ] Design test strategy and plan +- [ ] Write handler tests (RED phase) +- [ ] Implement `NewAnalyzeHandler` in handler.go +- [ ] Update `NewHealthHandler` signature and behavior +- [ ] Update `NewRouter` to accept analyzer and register analyze route +- [ ] Update `main.go` for new router signature +- [ ] Update router tests for analyze endpoint +- [ ] Run all tests and verify GREEN +- [ ] Refactor and validate +- [ ] Commit diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 6f5935a..2bd17fb 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -18,7 +18,11 @@ func main() { logger := config.NewLogger(cfg.LogLevel) - router := api.NewRouter(logger, cfg) + // TODO: Construct analyzer pipeline dependencies (rag.Pipeline, llm.LLMClient, + // embedding.EmbeddingClient, vectorstore.VectorStore, cache) and pass + // the *analyzer.Analyzer as the pipeline parameter. + // TODO: Pass a Qdrant health check function once the vector store is wired. + router := api.NewRouter(logger, cfg, nil, nil) addr := ":" + cfg.Port logger.Info("starting server", "addr", addr) diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go index 74c246d..16ef51d 100644 --- a/backend/internal/api/handler.go +++ b/backend/internal/api/handler.go @@ -1,25 +1,103 @@ package api import ( + "context" + "encoding/json" + "errors" + "log/slog" "net/http" + "github.com/parth/smolterms/backend/internal/analyzer" "github.com/parth/smolterms/backend/internal/config" "github.com/parth/smolterms/backend/internal/types" ) +// PipelineRunner abstracts the analysis pipeline for testability. +// *analyzer.Analyzer satisfies this interface. +type PipelineRunner interface { + Analyze(ctx context.Context, req analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) +} + +// NewAnalyzeHandler returns a handler that accepts HTML content and a URL, +// runs the analysis pipeline, and returns the result as JSON. +// If pipeline is nil, the handler returns 503 Service Unavailable. +// If logger is nil, the default slog logger is used. +func NewAnalyzeHandler(pipeline PipelineRunner, logger *slog.Logger) http.HandlerFunc { + if logger == nil { + logger = slog.Default() + } + return func(w http.ResponseWriter, r *http.Request) { + if pipeline == nil { + WriteError(w, http.StatusServiceUnavailable, "Analysis service not configured") + return + } + + var req analyzer.AnalysisRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + WriteError(w, http.StatusBadRequest, "Invalid JSON in request body") + return + } + + if req.URL == "" { + WriteError(w, http.StatusBadRequest, "The url field is required and must be non-empty") + return + } + if req.HTML == "" { + WriteError(w, http.StatusBadRequest, "The html field is required and must be non-empty") + return + } + + requestID := RequestIDFromContext(r.Context()) + + result, err := pipeline.Analyze(r.Context(), req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) { + logger.Error("analysis timed out", + "url", req.URL, + "request_id", requestID, + "error", err, + ) + WriteError(w, http.StatusGatewayTimeout, "Analysis timed out. Please try again.") + return + } + + logger.Error("analysis failed", + "url", req.URL, + "request_id", requestID, + "error", err, + ) + WriteError(w, http.StatusBadGateway, "Analysis service temporarily unavailable. Please try again.") + return + } + + WriteJSON(w, http.StatusOK, result) + } +} + // NewHealthHandler returns a handler that reports the health status of the server // and its configured services. cfg may be nil (services will show as not_configured). -func NewHealthHandler(cfg *config.Config) http.HandlerFunc { +// qdrantCheck is an optional function that checks Qdrant connectivity. If nil, +// the handler falls back to showing the configured Qdrant URL. +func NewHealthHandler(cfg *config.Config, qdrantCheck func(ctx context.Context) error) http.HandlerFunc { if cfg == nil { cfg = &config.Config{} } return func(w http.ResponseWriter, r *http.Request) { - qdrant := cfg.QdrantURL - if qdrant == "" { - qdrant = "localhost:6334" + qdrantStatus := cfg.QdrantURL + if qdrantStatus == "" { + qdrantStatus = "localhost:6334" } + + if qdrantCheck != nil { + if err := qdrantCheck(r.Context()); err != nil { + qdrantStatus = "unavailable" + } else { + qdrantStatus = "connected" + } + } + services := map[string]string{ - "qdrant": qdrant, + "qdrant": qdrantStatus, "anthropic": apiKeyStatus(cfg.AnthropicAPIKey), "openai": apiKeyStatus(cfg.OpenAIAPIKey), } diff --git a/backend/internal/api/handler_test.go b/backend/internal/api/handler_test.go index 26e7cd9..c1a996c 100644 --- a/backend/internal/api/handler_test.go +++ b/backend/internal/api/handler_test.go @@ -1,11 +1,17 @@ package api import ( + "context" "encoding/json" + "errors" + "fmt" "net/http" "net/http/httptest" + "strings" "testing" + "time" + "github.com/parth/smolterms/backend/internal/analyzer" "github.com/parth/smolterms/backend/internal/config" ) @@ -20,9 +26,254 @@ func newTestConfig() *config.Config { } } +// mockPipeline implements PipelineRunner for testing. +type mockPipeline struct { + result *analyzer.AnalysisResult + err error +} + +func (m *mockPipeline) Analyze(_ context.Context, _ analyzer.AnalysisRequest) (*analyzer.AnalysisResult, error) { + return m.result, m.err +} + +func TestAnalyzeHandler(t *testing.T) { + successResult := &analyzer.AnalysisResult{ + URL: "https://example.com/privacy", + OverallScore: 7.5, + RiskLevel: analyzer.RiskModerate, + Dimensions: map[string]analyzer.DimScore{ + analyzer.DimDataCollection: {Score: 8.0, Summary: "Good data collection practices"}, + analyzer.DimDataSharing: {Score: 7.0, Summary: "Moderate data sharing"}, + analyzer.DimUserRights: {Score: 7.5, Summary: "Adequate user rights"}, + analyzer.DimRetention: {Score: 7.0, Summary: "Reasonable retention"}, + analyzer.DimSecurity: {Score: 8.0, Summary: "Strong security measures"}, + }, + KeyConcerns: []string{"Third-party analytics"}, + Summary: "Overall moderate privacy practices", + Cached: false, + AnalyzedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + + t.Run("valid request returns 200 with analysis result", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Privacy Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusOK { + t.Errorf("status code = %d, want %d", got, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.URL != successResult.URL { + t.Errorf("url = %q, want %q", got.URL, successResult.URL) + } + if got.OverallScore != successResult.OverallScore { + t.Errorf("overall_score = %v, want %v", got.OverallScore, successResult.OverallScore) + } + if got.RiskLevel != successResult.RiskLevel { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, successResult.RiskLevel) + } + }) + + t.Run("valid request sets content-type to application/json", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Header().Get("Content-Type"); got != "application/json" { + t.Errorf("Content-Type = %q, want %q", got, "application/json") + } + }) + + t.Run("missing url returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "url") { + t.Errorf("error message = %q, want it to mention 'url'", got["error"]) + } + }) + + t.Run("missing html returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":""}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "html") { + t.Errorf("error message = %q, want it to mention 'html'", got["error"]) + } + }) + + t.Run("malformed JSON returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{not valid json}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got["error"] == "" { + t.Error("expected non-empty error message") + } + }) + + t.Run("empty body returns 400", func(t *testing.T) { + mock := &mockPipeline{result: successResult} + handler := NewAnalyzeHandler(mock, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", nil) + + handler(w, r) + + if got := w.Code; got != http.StatusBadRequest { + t.Errorf("status code = %d, want %d", got, http.StatusBadRequest) + } + }) + + t.Run("analyzer timeout returns 504", func(t *testing.T) { + mock := &mockPipeline{err: context.DeadlineExceeded} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusGatewayTimeout { + t.Errorf("status code = %d, want %d", got, http.StatusGatewayTimeout) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "timed out") { + t.Errorf("error message = %q, want it to mention 'timed out'", got["error"]) + } + }) + + t.Run("wrapped deadline exceeded returns 504", func(t *testing.T) { + mock := &mockPipeline{err: fmt.Errorf("analyze: %w", context.DeadlineExceeded)} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusGatewayTimeout { + t.Errorf("status code = %d, want %d", got, http.StatusGatewayTimeout) + } + }) + + t.Run("analyzer error returns 502", func(t *testing.T) { + mock := &mockPipeline{err: errors.New("llm service unavailable")} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusBadGateway { + t.Errorf("status code = %d, want %d", got, http.StatusBadGateway) + } + + var got map[string]string + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if !strings.Contains(strings.ToLower(got["error"]), "unavailable") { + t.Errorf("error message = %q, want it to mention 'unavailable'", got["error"]) + } + }) + + t.Run("non-policy result returns 200", func(t *testing.T) { + notPolicyResult := &analyzer.AnalysisResult{ + URL: "https://example.com/about", + RiskLevel: analyzer.RiskNotPolicy, + AnalyzedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), + } + mock := &mockPipeline{result: notPolicyResult} + handler := NewAnalyzeHandler(mock, nil) + + body := `{"url":"https://example.com/about","html":"About Us"}` + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodPost, "/api/v1/analyze", strings.NewReader(body)) + + handler(w, r) + + if got := w.Code; got != http.StatusOK { + t.Errorf("status code = %d, want %d", got, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.RiskLevel != analyzer.RiskNotPolicy { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, analyzer.RiskNotPolicy) + } + }) +} + func TestHealthHandler(t *testing.T) { cfg := newTestConfig() - handler := NewHealthHandler(cfg) + handler := NewHealthHandler(cfg, nil) t.Run("returns 200 status", func(t *testing.T) { w := httptest.NewRecorder() @@ -80,14 +331,11 @@ func TestHealthHandler(t *testing.T) { if got.Services["openai"] != "configured" { t.Errorf("openai = %q, want %q", got.Services["openai"], "configured") } - if got.Services["qdrant"] != "localhost:6334" { - t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "localhost:6334") - } }) t.Run("reports not_configured when API keys are empty", func(t *testing.T) { emptyCfg := &config.Config{QdrantURL: "localhost:6334"} - h := NewHealthHandler(emptyCfg) + h := NewHealthHandler(emptyCfg, nil) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) @@ -106,4 +354,60 @@ func TestHealthHandler(t *testing.T) { t.Errorf("openai = %q, want %q", got.Services["openai"], "not_configured") } }) + + t.Run("qdrant connected when health check succeeds", func(t *testing.T) { + check := func(_ context.Context) error { return nil } + h := NewHealthHandler(cfg, check) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "connected" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "connected") + } + }) + + t.Run("qdrant unavailable when health check fails", func(t *testing.T) { + check := func(_ context.Context) error { return errors.New("connection refused") } + h := NewHealthHandler(cfg, check) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "unavailable" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "unavailable") + } + }) + + t.Run("qdrant shows URL when no health check provided", func(t *testing.T) { + h := NewHealthHandler(cfg, nil) + + w := httptest.NewRecorder() + r := httptest.NewRequest(http.MethodGet, "/api/v1/health", nil) + h(w, r) + + var got struct { + Services map[string]string `json:"services"` + } + if err := json.NewDecoder(w.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode response body: %v", err) + } + if got.Services["qdrant"] != "localhost:6334" { + t.Errorf("qdrant = %q, want %q", got.Services["qdrant"], "localhost:6334") + } + }) } diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 8abb5ea..96ca0f2 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -1,6 +1,7 @@ package api import ( + "context" "log/slog" "net/http" "time" @@ -13,13 +14,16 @@ const defaultRequestTimeout = 60 * time.Second // NewRouter creates and returns a fully configured HTTP handler with // registered routes and middleware. -// Middleware order (outermost to innermost): CORS → Request ID → Timeout → Logging → routes. +// Middleware order (outermost to innermost): CORS -> Request ID -> Timeout -> Logging -> routes. // If logger is nil, the default slog logger is used. // If cfg is nil, the health handler uses zero-value config defaults. -func NewRouter(logger *slog.Logger, cfg *config.Config) http.Handler { +// If pipeline is nil, the analyze endpoint returns 503 Service Unavailable. +// qdrantCheck is an optional function for the health endpoint to verify Qdrant connectivity. +func NewRouter(logger *slog.Logger, cfg *config.Config, pipeline PipelineRunner, qdrantCheck func(ctx context.Context) error) http.Handler { mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/health", NewHealthHandler(cfg)) + mux.HandleFunc("GET /api/v1/health", NewHealthHandler(cfg, qdrantCheck)) + mux.HandleFunc("POST /api/v1/analyze", NewAnalyzeHandler(pipeline, logger)) // Catch-all for unmatched routes to return JSON 404. mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/api/router_test.go b/backend/internal/api/router_test.go index 8eb2f99..eed1f1b 100644 --- a/backend/internal/api/router_test.go +++ b/backend/internal/api/router_test.go @@ -4,11 +4,14 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" + + "github.com/parth/smolterms/backend/internal/analyzer" ) func TestNewRouter(t *testing.T) { - router := NewRouter(nil, nil) + router := NewRouter(nil, nil, nil, nil) server := httptest.NewServer(router) defer server.Close() @@ -112,3 +115,84 @@ func TestNewRouter(t *testing.T) { } }) } + +func TestNewRouterAnalyzeEndpoint(t *testing.T) { + successResult := &analyzer.AnalysisResult{ + URL: "https://example.com/privacy", + OverallScore: 7.5, + RiskLevel: analyzer.RiskModerate, + } + mock := &mockPipeline{result: successResult} + router := NewRouter(nil, nil, mock, nil) + server := httptest.NewServer(router) + defer server.Close() + + t.Run("POST /api/v1/analyze routes to handler", func(t *testing.T) { + body := `{"url":"https://example.com/privacy","html":"Policy"}` + resp, err := http.Post(server.URL+"/api/v1/analyze", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusOK) + } + + var got analyzer.AnalysisResult + if err := json.NewDecoder(resp.Body).Decode(&got); err != nil { + t.Fatalf("failed to decode body: %v", err) + } + if got.RiskLevel != analyzer.RiskModerate { + t.Errorf("risk_level = %q, want %q", got.RiskLevel, analyzer.RiskModerate) + } + }) + + t.Run("GET /api/v1/analyze returns 405 or 404", func(t *testing.T) { + resp, err := http.Get(server.URL + "/api/v1/analyze") + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + // Go's ServeMux with method-based routing may return 404 (via the catch-all) + // or 405 depending on how routes are matched. Both are acceptable. + if resp.StatusCode != http.StatusMethodNotAllowed && resp.StatusCode != http.StatusNotFound { + t.Errorf("status code = %d, want 405 or 404", resp.StatusCode) + } + }) + + t.Run("OPTIONS /api/v1/analyze returns 204 with CORS", func(t *testing.T) { + req, _ := http.NewRequest(http.MethodOptions, server.URL+"/api/v1/analyze", nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusNoContent) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "*" { + t.Errorf("Access-Control-Allow-Origin = %q, want %q", got, "*") + } + }) +} + +func TestNewRouterWithNilPipeline(t *testing.T) { + // When no pipeline is provided, POST to analyze should return 503 + router := NewRouter(nil, nil, nil, nil) + server := httptest.NewServer(router) + defer server.Close() + + body := `{"url":"https://example.com/privacy","html":"Policy"}` + resp, err := http.Post(server.URL+"/api/v1/analyze", "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusServiceUnavailable { + t.Errorf("status code = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable) + } +} From be59b9b29cbc47c724f9b42775cbaf7a6ddc356e Mon Sep 17 00:00:00 2001 From: Parth576 Date: Thu, 26 Feb 2026 00:29:23 -0500 Subject: [PATCH 3/3] use google uuid, add todo in readme --- backend/README.md | 5 +++++ backend/internal/api/middleware.go | 17 ++++------------- go.mod | 1 + 3 files changed, 10 insertions(+), 13 deletions(-) create mode 100644 backend/README.md diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..c1d5878 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,5 @@ +## Todo + +[] - Make the request timeout an env config option +[] - Decide how to prevent multiple requests by same user or different users - how to set up limits and prevent DOS +[] - Testing metrics with some privacy policy datasets to evaluate model and chunking/retrieval strategies \ No newline at end of file diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 88ae852..80f3f6e 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -2,11 +2,11 @@ package api import ( "context" - "crypto/rand" - "fmt" "log/slog" "net/http" "time" + + "github.com/google/uuid" ) // contextKey is an unexported type for context keys in this package, @@ -122,16 +122,7 @@ func TimeoutMiddleware(timeout time.Duration) func(http.Handler) http.Handler { } } -// generateUUID generates a UUID v4 string using crypto/rand. +// generateUUID generates a UUID v4 string using google/uuid. func generateUUID() string { - var uuid [16]byte - _, _ = rand.Read(uuid[:]) - - // Set version to 4 (random) - uuid[6] = (uuid[6] & 0x0f) | 0x40 - // Set variant to RFC 4122 - uuid[8] = (uuid[8] & 0x3f) | 0x80 - - return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", - uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:16]) + return uuid.NewString() } diff --git a/go.mod b/go.mod index 68f59da..56ca538 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.25.7 require ( github.com/PuerkitoBio/goquery v1.11.0 github.com/anthropics/anthropic-sdk-go v1.26.0 + github.com/google/uuid v1.6.0 github.com/openai/openai-go v1.12.0 github.com/patrickmn/go-cache v2.1.0+incompatible github.com/qdrant/go-client v1.16.0