Skip to content

flyingdev/health-metric-api

Repository files navigation

Health Metric Extraction API

A Go service that extracts structured health metrics from free-text clinical notes. Given a block of consultation text, the API identifies and normalises weight and height measurements into a consistent JSON response.

Quick Start

Docker (recommended)

docker compose up --build

Local

go run ./cmd/server

The server starts on port 8080 by default.

Configuration

All configuration is read from environment variables with sensible defaults.

Variable Default Description
PORT 8080 HTTP listen port
READ_TIMEOUT 10s Maximum duration for reading request
WRITE_TIMEOUT 10s Maximum duration for writing response
IDLE_TIMEOUT 60s Maximum duration for keep-alive
SHUTDOWN_TIMEOUT 15s Graceful shutdown drain period
MAX_BODY_BYTES 1048576 Maximum request body size (1 MB)
MAX_TEXT_LENGTH 10000 Maximum text field length (chars)

API Documentation

POST /parse

Accepts clinical free text and returns any health metrics found.

Request

curl -X POST http://localhost:8080/parse \
  -H "Content-Type: application/json" \
  -d '{
    "text": "Patient attended the clinic today with a pain in their left knee. Observations taken at the time were a weight of 9st 3lbs and height of 180cm tall. On examination there was a good range in movement and slight tenderness. Advised to use OTC ibuprofen gel and rest for 7 days."
  }'

Response

{
  "weight": "58.5kg",
  "height": "180cm"
}

Fields are omitted from the response when the corresponding metric is not found in the text.

Supported metrics

Metric Recognised formats Normalised to
Weight 75kg, 9st 3lbs, 165lbs, 80 kilograms kg
Height 180cm, 1.80m, 5ft 11in, 5'11" cm

Error responses

Status Meaning Example body
400 Missing or empty text field {"error":"text field is required and must not be empty"}
400 Malformed JSON {"error":"invalid JSON: ..."}
400 Unknown fields in request {"error":"invalid JSON: ..."}
400 Text exceeds max length {"error":"text exceeds maximum length"}
415 Wrong Content-Type {"error":"Content-Type must be application/json"}

GET /health

Returns {"status":"ok"} for use by container orchestrators and load balancers.

Running Tests

The project has three layers of tests (testing pyramid):

  • Unit tests — each extractor tested in isolation with table-driven tests
  • Handler tests — HTTP layer tested with both real parser and mock parser
  • Integration tests — full HTTP stack (router → middleware → handler → parser)
go test ./... -race -count=1

# Verbose
go test ./... -race -count=1 -v

# Benchmarks
go test ./internal/parser -bench=. -benchmem

# Lint (requires golangci-lint)
golangci-lint run ./...

Design Patterns

This project applies five recognised design patterns, each chosen for a specific architectural benefit:

1. Strategy Pattern — metric extractors

Each metric type (weight, height) is extracted by a separate struct that implements the MetricExtractor interface:

type MetricExtractor interface {
    Extract(text string) (value string, found bool)
}

The Parser delegates to its extractors without knowing their internals. This means adding a new metric (e.g. blood pressure) requires only writing a new struct that implements Extract — zero changes to existing code. This is the Open/Closed Principle in practice.

2. Dependency Inversion — handler interface

The handler defines a local Parser interface for what it needs:

// in handler package — no import of parser package
type Parser interface {
    Parse(text string) model.ParseResponse
}

The concrete *parser.Parser satisfies this interface implicitly. The handler package never imports the parser package — it depends only on the behaviour it needs. This is the canonical Go pattern: accept interfaces, return structs.

3. Functional Options — handler configuration

The handler uses the functional options pattern for extensible configuration:

h := handler.New(p,
    handler.WithMaxTextLength(cfg.MaxTextLength),
)

This pattern scales without breaking existing call sites. Adding a new option (e.g. WithLogger) only requires a new With* function — no constructor signature changes, no config struct versioning.

4. Decorator / Chain of Responsibility — middleware

Middleware functions wrap the HTTP handler with cross-cutting concerns:

app := middleware.Chain(mux,
    middleware.Recovery,      // outermost — catches panics
    middleware.SecureHeaders, // X-Content-Type-Options, X-Frame-Options
    middleware.Logger,        // logs every request
    middleware.MaxBodySize(cfg.MaxBodyBytes),
)

Each middleware is a func(http.Handler) http.Handler that decorates the next handler. Chain composes them in order. This is both the Decorator pattern (wrapping behaviour) and Chain of Responsibility (each middleware decides whether to pass the request along).

5. 12-Factor Configuration

All operational knobs are in a single config package reading from environment variables with documented defaults:

cfg := config.Load()  // reads PORT, READ_TIMEOUT, MAX_TEXT_LENGTH, etc.

Invalid environment values fall back to defaults silently. This follows the 12-factor app methodology and makes every configuration option discoverable.

Design Decisions

Standard library only

The service uses only Go's standard library (net/http, encoding/json, regexp, log/slog). Go 1.22's method-based routing (mux.HandleFunc("POST /parse", ...)) removes the need for a third-party router. Zero external dependencies means zero supply-chain risk.

Regex over NLP

For structured clinical observations ("weight of 9st 3lbs"), regex is deterministic, requires no external services, adds zero latency, and is straightforward to test.

Clinical validation ranges

Extracted values are validated against plausible ranges (weight 20–500 kg, height 50–300 cm) to prevent false positives from incidental numbers (e.g. "walked 100m" is not a height).

Strict input validation

Defence-in-depth at the HTTP boundary: Content-Type enforcement, DisallowUnknownFields, trailing data rejection (dec.More()), configurable text length limit, and middleware body size cap.

Graceful shutdown

The server listens for SIGINT/SIGTERM and drains in-flight connections with a configurable timeout.

Multi-stage Docker build

Two-stage build: golang:1.23-alpinealpine:3.20. ~15 MB image, non-root user, HEALTHCHECK directive.

Project Structure

health-metric-api/
├── cmd/server/
│   └── main.go                     # Entry point — config, wiring, shutdown
├── internal/
│   ├── config/
│   │   ├── config.go               # Centralised configuration from env vars
│   │   └── config_test.go          # Defaults, overrides, invalid fallback
│   ├── handler/
│   │   ├── handler.go              # HTTP handlers, Parser interface
│   │   └── handler_test.go         # Handler integration tests
│   ├── parser/
│   │   ├── parser.go               # MetricExtractor interface, Parser
│   │   ├── extractors.go           # Weight and height extractors
│   │   ├── parser_test.go          # Unit tests and benchmarks
│   │   └── example_test.go         # Example tests (runnable documentation)
│   ├── model/
│   │   └── model.go                # Request/response types
│   └── middleware/
│       ├── middleware.go            # Logging, recovery, body size limit
│       └── middleware_test.go       # Middleware unit tests
├── integration_test.go               # End-to-end HTTP stack tests
├── .golangci.yml                    # Linter configuration
├── Dockerfile                       # Multi-stage production build
├── docker-compose.yml               # One-command local startup
├── Makefile                         # Development task runner
└── go.mod

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors