A Go-based API middleware service built with Gin Web Framework, featuring PostgreSQL database integration and an Nginx reverse proxy with SSL/TLS (HTTPS) support.
- Features
- Architecture & Services
- Project Structure
- Prerequisites
- Quick Start with Docker Compose (3 Minutes)
- Docker Compose Guide
- Local Go Development (Without Docker API)
- API Endpoints & Testing
- Database Migrations
- Database Seeder Deep Dive
- Testing (TDD Suite)
- Environment Variables
- Troubleshooting & FAQ
- Gin HTTP Server: Fast and lightweight REST API framework with graceful shutdown (SIGINT/SIGTERM trapping) and HTTP server timeout protection (
ReadTimeout,WriteTimeout,IdleTimeout). - Nginx Reverse Proxy & HTTP/2: Production-ready reverse proxy with HTTP-to-HTTPS redirection via
308 Permanent Redirect(preserving HTTP request method and payload), upstream keepalive connection pooling, and HTTP/2 protocol support. - SSL/TLS (HTTPS): Preconfigured Nginx reverse proxy with SAN-enabled SSL certificates supporting
localhost, IPv4127.0.0.1, and IPv6::1. - Security Hardening: Nginx version banner hidden (
server_tokens off), strict HTTP security headers (X-Content-Type-Options,X-Frame-Options,X-XSS-Protection,Referrer-Policy), and strict SSL file permissions (600for private key,644for certificate). - PostgreSQL 18 Integration: Database layer with connection pooling (
DB_MAX_OPEN_CONNS,DB_MAX_IDLE_CONNS, lifetime & idle time tuning), health check probes, and automated schema migrations. - JWT Authentication & Session Persistence: Secure session management using HMAC-SHA256 (
HS256) JWT tokens stored in thestaff_sessiondatabase table and dispatched to clients viaHttpOnly,SameSite=Lax, andSecurecookies. - Strict Password & Identity Policy: Alphanumeric username validation, comprehensive password policy (8–32 chars, ASCII English only, lowercase, uppercase, digit, and special symbol), and password hashing via
bcrypt. - RFC 7807 Problem Details: Centralized error handling returning standardized
application/problem+jsonerror responses with distinct error URIs and descriptive titles. - Dynamic CORS Middleware: Origin whitelist filtering (
CORS_ALLOWED_ORIGINS), credential forwarding (Access-Control-Allow-Credentials: true), and preflight caching (CORS_MAX_AGE_SECONDS). - Dockerized: Multi-stage minimal non-root Alpine container with automated healthcheck orchestration on
bridge-network. - Database Migrations: Embedded migrations (000001 through 000004) using
golang-migrateand Goembed.FS. - Database Seeder: Standalone CLI tool (
cmd/seed) with environment safety guards and idempotent single-transaction seeding. - Interactive Swagger / OpenAPI 2.0: Embedded Swagger UI served at
/swagger/*anyusingswaggo/gin-swagger, providing interactive endpoint documentation, schemas, and live testing in both HTTP and HTTPS modes.
When running with Docker Compose, three services are orchestrated in the bridge-network bridge network:
[Client / Browser / curl]
│
├─────────────────────────────────────────┐
▼ (Port 80 / 443) ▼ (Port 8080 - Optional Debug)
┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ Nginx │ │ Go API Service │
│ (nginx) │──────>│ (hospital-middleware-api) │
│ - Port 80 (HTTP -> 308 HTTPS) │ │ - Port 8080 │
│ - Port 443 (HTTPS / SSL) │ └────────────────┬────────────────┘
└─────────────────────────────────┘ │ (Port 5432)
▼
┌─────────────────────────────────┐
│ PostgreSQL │
│ (postgres) │
│ - Port 5432 │
└─────────────────────────────────┘
| Service | Container Name | Internal Port | Exposed Host Port | Description |
|---|---|---|---|---|
nginx |
nginx |
80, 443 |
80, 443 |
Reverse proxy, SSL termination, and HTTP 308 redirect |
hospital-middleware-api |
hospital-middleware-api |
8080 |
8080 |
Go Gin REST API application |
postgres |
postgres |
5432 |
5432 |
PostgreSQL 18.4 database |
hospital-middleware/
├── cmd/
│ ├── api/
│ │ ├── main.go # Application entrypoint, DI, server timeouts & graceful shutdown
│ │ └── main_test.go # API route integration tests (mocked & live DB)
│ └── seed/
│ └── main.go # Standalone database seeder CLI
├── docs/ # Auto-generated Swagger/OpenAPI documentation
│ ├── docs.go # Go package registering Swagger specification
│ ├── swagger.json # OpenAPI specification in JSON
│ └── swagger.yaml # OpenAPI specification in YAML
├── internal/
│ ├── auth/ # Authentication & JWT token service (HS256)
│ │ ├── jwt.go # Token generator & claims validator
│ │ └── jwt_test.go # JWT unit tests
│ ├── config/
│ │ ├── config.go # Configuration loader, environment validator, and DSN builder
│ │ └── config_test.go # Config unit tests
│ ├── database/
│ │ ├── migrate.go # Embedded migration executor (golang-migrate)
│ │ ├── migrate_test.go # Migration runner unit tests
│ │ ├── postgres.go # PostgreSQL connection pool & management
│ │ ├── postgres_test.go # Database connection TDD suite
│ │ └── seeds/ # Mockup datasets & database seeder logic
│ │ ├── hospital.go # Hospital mock data & transaction seeder
│ │ ├── hospital_test.go # Seed data unit tests
│ │ └── seeder.go # Seeder coordinator
│ ├── handlers/
│ │ ├── health_handler.go # Health & readiness probe HTTP handlers
│ │ ├── health_handler_test.go # Health handler unit tests (mocked DB)
│ │ ├── staff_handler.go # Staff HTTP handler (/staff/create, /staff/login) & password validator
│ │ ├── staff_handler_test.go # Staff handler unit tests
│ │ └── routes.go # Route registration & global middleware attachment
│ ├── middleware/
│ │ ├── cors.go # Dynamic CORS middleware (origin whitelist, credentials, max-age)
│ │ ├── cors_test.go # CORS unit tests
│ │ ├── error_handler.go # Centralized RFC 7807 Problem Details error handler
│ │ └── error_handler_test.go # Error handler unit tests
│ ├── models/ # Domain entities and data structures
│ │ ├── hospital.go # Hospital domain entity
│ │ ├── staff.go # Staff domain entity
│ │ └── staff_session.go # Staff session domain entity
│ ├── repository/ # Database access & query layer (Data persistence)
│ │ ├── hospital_repository.go # Hospital data access implementation
│ │ ├── hospital_repository_test.go # Hospital repository unit & integration tests
│ │ ├── staff_repository.go # Staff data access implementation
│ │ ├── staff_repository_test.go # Staff repository unit & integration tests
│ │ ├── staff_session_repository.go # Staff session data access implementation
│ │ └── staff_session_repository_test.go # Staff session repository unit & integration tests
│ ├── response/ # Reusable JSend / Custom Envelope response package
│ │ ├── response.go # Generic response envelope & helpers
│ │ └── response_test.go # Response envelope unit tests
│ └── service/ # Business logic layer (Domain rules)
│ ├── staff_service.go # Staff business logic (bcrypt, JWT issuance, session persistence)
│ └── staff_service_test.go # Staff service unit tests
├── migrations/ # SQL migration scripts (.up.sql & .down.sql)
│ ├── 000001_create_hospital_table.up.sql
│ ├── 000001_create_hospital_table.down.sql
│ ├── 000002_create_staff_table.up.sql
│ ├── 000002_create_staff_table.down.sql
│ ├── 000003_create_patient_table.up.sql
│ ├── 000003_create_patient_table.down.sql
│ ├── 000004_create_staff_session_table.up.sql
│ ├── 000004_create_staff_session_table.down.sql
│ └── migrations.go # Go embed.FS binding for migrations
├── nginx/ # Nginx reverse proxy configuration
│ ├── default.conf # Reverse proxy, upstream keepalive, HTTP 308, security headers
│ └── ssl/ # SSL certificate directory (Git-ignored)
│ ├── .gitkeep # Tracks directory structure in Git
│ ├── generate-cert.sh # Script to generate local SSL certificates (SAN: IPv4 & IPv6)
│ ├── server.crt # Public SSL certificate (generated)
│ └── server.key # Private SSL key (generated)
├── Dockerfile # Multi-stage Docker build
├── docker-compose.yml # Docker Compose service definition
├── .dockerignore # Excluded build context files
├── .env.example # Template for environment variables
├── go.mod # Go module definitions
├── go.sum # Dependency checksums
└── README.md
- For Docker Compose (Recommended):
- Docker:
20.10+(or Docker Desktop) - Docker Compose:
v2+ - (That's all! You do not need Go, PostgreSQL, or OpenSSL installed locally on your host machine to run, seed, or test the stack).
- Docker:
- For Host Development (Go native outside Docker):
- Go:
1.25.0or higher - OpenSSL: (used by
generate-cert.shon host; Docker fallback is automatic) - swag CLI: (optional, for regenerating Swagger docs):
go install github.com/swaggo/swag/cmd/swag@latest - golang-migrate: (optional, for running manual migrations via CLI)
- Go:
Get the entire stack (PostgreSQL + API + Nginx SSL + Seeded Mock Data) up and running in one copy-paste command:
# One-liner quick start:
cp .env.example .env && \
chmod +x ./nginx/ssl/generate-cert.sh && ./nginx/ssl/generate-cert.sh && \
docker compose up -d --build && \
docker run --rm --network bridge-network -v "$PWD":/app -w /app --env-file .env -e DB_HOST=postgres golang:1.25-alpine go run ./cmd/seedOr follow the step-by-step instructions below:
git clone https://github.com/scale10n/hospital-middleware.git
cd hospital-middleware
cp .env.example .envThe default variables in
.env.examplework out of the box for local Docker Compose development.
Nginx mounts ./nginx/ssl to terminate HTTPS. Because private keys and certificates are excluded from Git (.gitignore), generate them once:
chmod +x ./nginx/ssl/generate-cert.sh
./nginx/ssl/generate-cert.shTip: If
opensslis not installed on your host system, the script automatically uses Docker (alpine) to generate SAN-enabled certificates. Windows users can run this in Git Bash or WSL.
docker compose up -d --buildThis builds and launches the containers in sequence with automated health checks:
postgres(PostgreSQL 18.4) starts on port5432.hospital-middleware-apiwaits for PostgreSQL, auto-applies database migrations (DB_AUTO_MIGRATE=true), and starts on port8080.nginxwaits for the API health probe, binds ports80and443, and terminates SSL.
Check container status:
docker compose ps(All 3 services will show status Up (healthy) after ~10-15 seconds).
When started for the first time, the database tables are migrated but empty. Running the seeder inserts 10 hospitals (HOSP001 - HOSP010) and 45 patients required for staff authentication and patient search:
# Run seeder via Docker (No local Go installation required!)
docker run --rm --network bridge-network -v "$PWD":/app -w /app \
--env-file .env -e DB_HOST=postgres \
golang:1.25-alpine go run ./cmd/seed(Or if you have Go installed on your host machine: go run ./cmd/seed)
- Health Check:
curl -k https://localhost/health # Expected: {"database":"connected","status":"healthy"} - Interactive Swagger UI: Open in your browser:
- Via HTTPS (Nginx Gateway): https://localhost/swagger/index.html
- Via HTTP (Direct API Debug): http://localhost:8080/swagger/index.html
The project uses .env for container orchestration. Key settings:
| Variable | Default | Purpose in Docker Compose |
|---|---|---|
DB_USER |
admin |
PostgreSQL superuser username |
DB_PASSWORD |
your_secure_password |
PostgreSQL superuser password (matches in db & api) |
DB_NAME |
hospital_middleware_db |
Application database name |
PORT |
8080 |
Internal API port |
NGINX_PORT |
80 |
Host port for HTTP (redirects 308 to HTTPS) |
NGINX_SSL_PORT |
443 |
Host port for HTTPS gateway |
JWT_SECRET |
(32+ chars) | HMAC-SHA256 secret for staff session tokens |
DB_AUTO_MIGRATE |
true |
Automatically runs embedded migrations on API startup |
The script ./nginx/ssl/generate-cert.sh creates a 2048-bit RSA certificate valid for 365 days with Subject Alternative Names (SAN) supporting:
localhost- IPv4 loopback:
127.0.0.1 - IPv6 loopback:
::1
# Default (localhost, 365 days):
./nginx/ssl/generate-cert.sh
# Custom domain and duration:
./nginx/ssl/generate-cert.sh api.local 730# Start all containers in detached mode:
docker compose up -d --build
# Verify container health status:
docker compose psExample healthy output:
NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
hospital-middleware-api hospital-middleware-hospital-middleware-api "/app/api" hospital-middleware-api 10 seconds ago Up 9 seconds (healthy) 0.0.0.0:8080->8080/tcp
nginx nginx:alpine "/docker-entrypoint.…" nginx 10 seconds ago Up 8 seconds (healthy) 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
postgres postgres:18.4 "docker-entrypoint.s…" postgres 10 seconds ago Up 10 seconds (healthy) 0.0.0.0:5432->5432/tcp
Without seed data, calling POST /staff/create with hospital: "HOSP001" will fail with 400 Bad Request (hospital not found).
Run the seeder with one of two options:
- Option A (Via Docker - No Go needed on host):
docker run --rm --network bridge-network -v "$PWD":/app -w /app \ --env-file .env -e DB_HOST=postgres \ golang:1.25-alpine go run ./cmd/seed - Option B (Via Host Go):
go run ./cmd/seed
Seeder features:
- Idempotent: Can be re-run safely at any time (
ON CONFLICT (hn) DO UPDATE). - Atomic: Runs inside a single database transaction.
- Selective Seeding: Pass
-table=hospitalor-table=patientto seed specific datasets.
| Action | Command |
|---|---|
| Start / Build stack | docker compose up -d --build |
| Stop stack (preserve data) | docker compose down |
| Stop & Delete database volume | docker compose down -v |
| View logs (all services) | docker compose logs -f |
| View API logs only | docker compose logs -f hospital-middleware-api |
| View Nginx logs only | docker compose logs -f nginx |
| View Postgres logs only | docker compose logs -f postgres |
| Rebuild API after code change | docker compose up -d --build hospital-middleware-api |
| Restart single service | docker compose restart <service-name> |
| Enter Postgres psql CLI | docker compose exec -it postgres psql -U admin -d hospital_middleware_db |
| Enter API container shell | docker compose exec -it hospital-middleware-api /bin/sh |
| Reload Nginx configuration | docker compose exec nginx nginx -s reload |
The PostgreSQL container exposes port 5432 on localhost. You can connect using your favorite database GUI client (DBeaver, TablePlus, DataGrip, pgAdmin) or CLI:
- Host:
localhost(or127.0.0.1) - Port:
5432(or the value ofDB_PORTin.env) - Database:
hospital_middleware_db - Username:
admin(or value ofDB_USERin.env) - Password:
your_secure_password(matchesDB_PASSWORDin.env) - SSL Mode:
disable
Or open an interactive SQL prompt directly:
docker compose exec -it postgres psql -U admin -d hospital_middleware_dbIf ports 80, 443, 8080, or 5432 are already used by other services on your machine, customize them in .env:
# Avoid port 80/443 conflict on host:
NGINX_PORT=8088
NGINX_SSL_PORT=8443
# Avoid port 8080/5432 conflict:
PORT=8888
DB_PORT=5433Then restart with docker compose up -d. You can then access HTTPS via https://localhost:8443.
If you prefer to edit and run the Go code natively with live reload on your host machine while keeping PostgreSQL inside Docker:
-
Start only PostgreSQL:
docker compose up -d postgres
-
Ensure Go is available in your PATH:
export PATH=$PATH:/usr/local/go/bin:~/go/bin go version
-
Install dependencies:
go mod download
-
Seed database mock data:
go run ./cmd/seed
-
Run the API server:
go run ./cmd/api
-
Verify direct connection:
curl http://localhost:8080/health
| Method | Endpoint | Description | Status Code | Content-Type |
|---|---|---|---|---|
GET |
/swagger/*any |
Interactive Swagger UI API documentation | 200 OK |
text/html |
GET |
/health |
Readiness probe & PostgreSQL DB check | 200 OK |
application/json |
POST |
/staff/create |
Validate hospital HN, create staff member, hash password, persist session & issue JWT | 201 Created |
application/json |
POST |
/staff/login |
Authenticate staff credentials, persist session & issue JWT | 200 OK |
application/json |
POST |
/patient/search |
Search patients within staff's hospital matching all provided criteria (CookieAuth) | 200 OK |
application/json |
GET |
/patient/search/{id} |
Retrieve specific patient details by Thai National ID (13 digits) or Passport ID within staff's hospital (CookieAuth) | 200 OK |
application/json |
The API includes embedded, self-contained interactive documentation powered by Swagger UI.
- Direct (Local Dev): http://localhost:8080/swagger/index.html
- Via Nginx (HTTPS): https://localhost/swagger/index.html
- Raw JSON Spec:
http://localhost:8080/swagger/doc.json
To generate or update Swagger documentation from code annotations, install the swag CLI tool:
# Ensure Go bin directory is in PATH
export PATH=$PATH:$HOME/go/bin
# Install swag CLI
go install github.com/swaggo/swag/cmd/swag@latest
# Verify installation
swag --versionWhenever you add or update API annotations in cmd/api/main.go or internal/handlers/*.go, run:
swag init -g cmd/api/main.go -o ./docs --parseDependency --parseInternalThe generated files are saved into the docs/ directory:
docs/docs.go: Go source code registering the specification with Swagger at runtime.docs/swagger.json: OpenAPI 2.0 JSON specification.docs/swagger.yaml: OpenAPI 2.0 YAML specification.
{
"username": "somchai",
"password": "Password123!",
"hospital": "HOSP001"
}username(required, string):- Must be strictly alphanumeric (
[a-zA-Z0-9]). - Thai characters, spaces, and punctuation/special characters are rejected.
- Must be strictly alphanumeric (
password(required, string):- Length: between 8 and 32 characters.
- Allowed characters: English letters, numbers, and allowed ASCII symbols (
!@#$%^&*()-_=+[]{}|;:'",.<>/?~\`). - Character requirements:
- At least 1 lowercase letter (
a-z) - At least 1 uppercase letter (
A-Z) - At least 1 digit (
0-9) - At least 1 special character
- At least 1 lowercase letter (
- Rejects Thai characters, emojis, and non-ASCII characters.
hospital(required, string):- Must match an existing hospital HN in the database (e.g.
HOSP001).
- Must match an existing hospital HN in the database (e.g.
{
"username": "somchai",
"password": "Password123!",
"hospital": "HOSP001"
}The validation rules for username, password, and hospital are identical to POST /staff/create:
username(required, string):- Must be strictly alphanumeric (
[a-zA-Z0-9]). - Thai characters, spaces, and punctuation/special characters are rejected (
400 Bad Request).
- Must be strictly alphanumeric (
password(required, string):- Length: between 8 and 32 characters.
- Allowed characters: English letters, numbers, and allowed ASCII symbols (
!@#$%^&*()-_=+[]{}|;:'",.<>/?~\`). - Character requirements:
- At least 1 lowercase letter (
a-z) - At least 1 uppercase letter (
A-Z) - At least 1 digit (
0-9) - At least 1 special character
- At least 1 lowercase letter (
- Rejects Thai characters, emojis, spaces, and non-ASCII characters (
400 Bad Request).
hospital(required, string):- Must match an existing hospital HN in the database (e.g.
HOSP001). If not found, returns400 Bad Request(hospital not found).
- Must match an existing hospital HN in the database (e.g.
Accepts optional query parameters and/or JSON body:
national_id(optional, string): Thai Citizen National ID.passport_id(optional, string): Passport number.first_name(optional, string): Patient first name in Thai or English (supports case-insensitive partial match via PostgreSQLILIKE).middle_name(optional, string): Patient middle name in Thai or English (supports case-insensitive partial match via PostgreSQLILIKE).last_name(optional, string): Patient last name in Thai or English (supports case-insensitive partial match via PostgreSQLILIKE).date_of_birth(optional, string): FormatYYYY-MM-DD(e.g.1990-05-15).phone_number(optional, string): Patient contact number.email(optional, string): Patient email address.
Rules & Constraints:
- Authentication: Requires valid JWT session cookie
session_tokenissued at staff login (CookieAuth). Rejects unauthorized requests with401 Unauthorized. - Hospital Isolation: Results are strictly filtered to the authenticated staff member's hospital (
WHERE patient.hospital_id = staff.hospital_id). - Search Criteria: At least one of the 8 search criteria above must be non-empty (rejects empty search queries with
400 Bad Request). - AND Logic: Returns patients matching ALL provided filter criteria.
- Response Format: JSend success envelope with patient details and total count (
total).
Retrieves a single patient record by identity number within the authenticated staff's hospital:
id(required, path parameter): Thai National ID (strictly 13 digits, e.g.1100501234567) OR Foreign Passport ID (6 to 20 alphanumeric characters, case-insensitive, e.g.US556677889). Patient UUIDs are not accepted.
Rules & Constraints:
- Authentication: Requires valid JWT session cookie
session_token(CookieAuth). Rejects unauthorized requests with401 Unauthorized. - Hospital Isolation: Results are strictly filtered to the authenticated staff member's hospital (
WHERE p.hospital_id = staff.hospital_id AND (p.national_id = $2 OR p.passport_id ILIKE $2)). Patients from other hospitals return404 Not Found. - Validation: Rejects invalid identifier formats (must be 13 numeric digits or 6-20 alphanumeric characters) with
400 Bad Request. - Response Format: JSend success envelope containing all 14 patient fields (
id,patient_hn,first_name_th,middle_name_th,last_name_th,first_name_en,middle_name_en,last_name_en,date_of_birth,national_id,passport_id,phone_number,email,gender).
Note: Use
-k(or--insecure) because the SSL certificate is self-signed.
# Health & Database connection test
curl -k https://localhost/health
# Create Staff Member (JSend / Custom Envelope response & HttpOnly cookie)
curl -k -i -X POST https://localhost/staff/create \
-H "Content-Type: application/json" \
-d '{
"username": "somchai",
"password": "Password123!",
"hospital": "HOSP001"
}'HTTP 201 Created Response:
- Header:
Set-Cookie: session_token=<jwt_token>; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax; [Secure] - Response Body:
{
"status": "success",
"message": "staff created successfully",
"data": {
"staff": {
"id": "76ec965b-bf50-48b4-82a4-7935f8c6ebf7",
"username": "somchai",
"hospital_hn": "HOSP001",
"hospital_name": "โรงพยาบาลศิริราช (Siriraj Hospital)",
"created_at": "2026-10-08T17:34:38.123456Z"
},
"session": {
"expires_at": "2026-10-09T17:34:38.123456Z"
}
}
}# Staff Login (JSend / Custom Envelope response & HttpOnly cookie)
curl -k -i -X POST https://localhost/staff/login \
-H "Content-Type: application/json" \
-d '{
"username": "somchai",
"password": "Password123!",
"hospital": "HOSP001"
}'HTTP 200 OK Response:
- Header:
Set-Cookie: session_token=<jwt_token>; Path=/; Max-Age=86400; HttpOnly; SameSite=Lax; [Secure] - Response Body:
{
"status": "success",
"message": "login successful",
"data": {
"staff": {
"id": "76ec965b-bf50-48b4-82a4-7935f8c6ebf7",
"username": "somchai",
"hospital_hn": "HOSP001",
"hospital_name": "โรงพยาบาลศิริราช (Siriraj Hospital)",
"created_at": "2026-10-08T17:34:38.123456Z"
},
"session": {
"expires_at": "2026-10-09T17:34:38.123456Z"
}
}
}# Patient Search (requires session_token cookie from login)
curl -k -i -X POST "https://localhost/patient/search?first_name=Somchai" \
--cookie "session_token=<jwt_token>"HTTP 200 OK Response:
{
"status": "success",
"message": "patients retrieved successfully",
"data": {
"patients": [
{
"id": "c1f728ea-a312-4f35-905e-8b1d9bf5b012",
"patient_hn": "P00001",
"first_name_th": "สมชาย",
"middle_name_th": "วิชัย",
"last_name_th": "ใจดี",
"first_name_en": "Somchai",
"middle_name_en": "Wichai",
"last_name_en": "Jaidee",
"date_of_birth": "1985-04-12",
"national_id": "1100501234567",
"passport_id": "",
"phone_number": "0812345678",
"email": "somchai.jaidee@example.com",
"gender": "M"
}
],
"total": 1
}
}# Patient Search by Identity (Thai National ID or Passport ID, requires session_token cookie from login)
# Example 1: Search by 13-digit Thai National ID
curl -k -i -X GET "https://localhost/patient/search/1100501234567" \
--cookie "session_token=<jwt_token>"
# Example 2: Search by Foreign Passport ID
curl -k -i -X GET "https://localhost/patient/search/US556677889" \
--cookie "session_token=<jwt_token>"HTTP 200 OK Response:
{
"status": "success",
"message": "patient retrieved successfully",
"data": {
"id": "c1f728ea-a312-4f35-905e-8b1d9bf5b012",
"patient_hn": "P00001",
"first_name_th": "สมชาย",
"middle_name_th": "วิชัย",
"last_name_th": "ใจดี",
"first_name_en": "Somchai",
"middle_name_en": "Wichai",
"last_name_en": "Jaidee",
"date_of_birth": "1985-04-12",
"national_id": "1100501234567",
"passport_id": "",
"phone_number": "0812345678",
"email": "somchai.jaidee@example.com",
"gender": "M"
}
}Nginx issues an HTTP 308 Permanent Redirect, which guarantees that clients preserve the HTTP method (POST) and request body when redirecting to HTTPS:
# Inspect 308 Permanent Redirect response
curl -i http://localhost/health
# Automatically follow redirect (-L) for POST request
curl -k -L -i -X POST http://localhost/staff/create \
-H "Content-Type: application/json" \
-d '{
"username": "nurseann",
"password": "Password123!",
"hospital": "HOSP001"
}'# Health probe
curl http://localhost:8080/health
# Create Staff Member
curl -i -X POST http://localhost:8080/staff/create \
-H "Content-Type: application/json" \
-d '{
"username": "somchai",
"password": "Password123!",
"hospital": "HOSP001"
}'All API errors conform to the RFC 7807 specification with Content-Type application/problem+json:
Returned when the requested hospital HN does not exist in the database:
{
"type": "/errors/hospital-not-found",
"title": "Bad Request",
"status": 400,
"detail": "hospital not found",
"instance": "/staff/create",
"error": "hospital not found"
}Returned when the staff username is already registered for this hospital:
{
"type": "/errors/staff-already-exists",
"title": "Conflict",
"status": 409,
"detail": "staff already exists",
"instance": "/staff/create",
"error": "staff already exists"
}Returned when username format or password security policy rules fail:
{
"type": "/errors/validation-error",
"title": "Bad Request",
"status": 400,
"detail": "Key: 'CreateStaffRequest.Password' Error:Field validation for 'Password' failed on the 'password' tag",
"instance": "/staff/create",
"error": "Key: 'CreateStaffRequest.Password' Error:Field validation for 'Password' failed on the 'password' tag"
}Returned on POST /staff/login when the username is not found or password does not match:
{
"type": "/errors/unauthorized",
"title": "Unauthorized",
"status": 401,
"detail": "invalid username or password",
"instance": "/staff/login",
"error": "invalid username or password"
}Database schema migrations are managed using golang-migrate and stored in the migrations/ directory.
By default, the application embeds all SQL migration files in the binary using Go's embed.FS and applies pending migrations automatically when the API starts (DB_AUTO_MIGRATE=true in .env).
To disable auto-migrations in production or CI/CD pipelines, set:
DB_AUTO_MIGRATE=false| Version | Migration Name | Description |
|---|---|---|
000001 |
create_hospital_table |
Hospital entity table (id, hn, name, timestamps) |
000002 |
create_staff_table |
Staff entity table with hospital foreign key & unique (hospital_id, username) |
000003 |
create_patient_table |
Patient entity table |
000004 |
create_staff_session_table |
Staff authentication session table (staff_id, token, expires_at) with foreign key cascade & indices |
Install the migrate CLI tool:
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latestmigrate create -ext sql -dir migrations -seq <migration_name>migrate -path migrations -database "postgres://admin:postgres@localhost:5432/hospital_middleware_db?sslmode=disable" upmigrate -path migrations -database "postgres://admin:postgres@localhost:5432/hospital_middleware_db?sslmode=disable" down 1migrate -path migrations -database "postgres://admin:postgres@localhost:5432/hospital_middleware_db?sslmode=disable" versionA dedicated Go Seeder CLI is provided under cmd/seed/ to inject realistic mockup data into PostgreSQL for local development and testing, keeping the migrations/ directory clean and free of test-data pollution.
- Production Safeguard: Automatically prevents execution if
GIN_MODE=releaseorAPP_ENV=production, unless overridden with the-forceflag. - Idempotency: Utilizes
ON CONFLICT (hn) DO UPDATEso seeders can be re-run safely without primary or unique key violations. - Single Transaction: All inserts for each table run atomically inside a database transaction (
BeginTx).
| HN Code | Hospital Name (Thai & English) |
|---|---|
HOSP001 |
โรงพยาบาลศิริราช (Siriraj Hospital) |
HOSP002 |
โรงพยาบาลจุฬาลงกรณ์ สภากาชาดไทย (King Chulalongkorn Memorial Hospital) |
HOSP003 |
โรงพยาบาลรามาธิบดี (Ramathibodi Hospital) |
HOSP004 |
โรงพยาบาลกรุงเทพ (Bangkok Hospital) |
HOSP005 |
โรงพยาบาลบำรุงราษฎร์ (Bumrungrad International Hospital) |
HOSP006 - HOSP010 |
Samitivej, Phramongkutklao, Rajavithi, Thammasat, Maharaj Nakorn Chiang Mai |
- Thai Citizen Example: National ID
1100501234567(Somchai Jaidee, HospitalHOSP001) - Foreign Passport Example: Passport ID
US556677889(John Doe, HospitalHOSP001)
# Seed all tables (Hospitals + Patients):
docker run --rm --network bridge-network -v "$PWD":/app -w /app \
--env-file .env -e DB_HOST=postgres \
golang:1.25-alpine go run ./cmd/seed
# Seed only hospitals:
docker run --rm --network bridge-network -v "$PWD":/app -w /app \
--env-file .env -e DB_HOST=postgres \
golang:1.25-alpine go run ./cmd/seed -table=hospital
# Seed only patients:
docker run --rm --network bridge-network -v "$PWD":/app -w /app \
--env-file .env -e DB_HOST=postgres \
golang:1.25-alpine go run ./cmd/seed -table=patient# Seed all tables:
go run ./cmd/seed
# Seed specific table:
go run ./cmd/seed -table=hospital
go run ./cmd/seed -table=patient
# Bypass production safeguard (if needed):
go run ./cmd/seed -forceRun the full Go test suite (unit tests and database integration tests):
go test -v ./...Tip: If running outside standard system paths, ensure the Go binary directory is in your
PATH(e.g.,export PATH=$PATH:/usr/local/go/bin:~/go/bin).
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
Port for the Go HTTP server |
GIN_MODE |
debug |
Gin framework mode (debug or release; set to release in Docker Compose) |
NGINX_PORT |
80 |
Host port for Nginx HTTP (redirects to HTTPS via 308) |
NGINX_SSL_PORT |
443 |
Host port for Nginx HTTPS (SSL/TLS) |
DB_HOST |
localhost |
PostgreSQL host (postgres when inside Docker) |
DB_PORT |
5432 |
PostgreSQL port |
DB_USER |
admin |
PostgreSQL username |
DB_PASSWORD |
postgres |
PostgreSQL password |
DB_NAME |
hospital_middleware_db |
PostgreSQL database name |
DB_SSLMODE |
disable |
PostgreSQL SSL mode (disable, require, etc.) |
DB_AUTO_MIGRATE |
true |
Automatically run migrations on startup (true / false) |
DB_MAX_OPEN_CONNS |
25 |
Maximum number of open connections in PostgreSQL connection pool |
DB_MAX_IDLE_CONNS |
25 |
Maximum number of idle connections in PostgreSQL connection pool |
DB_CONN_MAX_LIFETIME_MINUTES |
5 |
Connection maximum lifetime in minutes before recycling |
DB_CONN_MAX_IDLE_TIME_MINUTES |
5 |
Maximum idle connection duration in minutes |
JWT_SECRET |
hospital-middleware-super-secret-key-32bytes |
Secret key used for signing and validating JWT session tokens (minimum 32 characters) |
JWT_EXPIRY_SECONDS |
86400 |
Expiration time for JWT session tokens in seconds (default: 24h) |
CORS_ALLOWED_ORIGINS |
http://localhost:3000,http://localhost:5173 |
Comma-separated list of allowed origins for CORS (supports * for all) |
CORS_MAX_AGE_SECONDS |
86400 |
Browser cache duration for CORS preflight OPTIONS in seconds (default: 24h) |
Symptoms: Nginx container exits with status code 1; log says: cannot load certificate "/etc/nginx/ssl/server.crt": BIO_new_file() failed.
Solution: SSL certificate files are git-ignored for security. Generate them once by running:
chmod +x ./nginx/ssl/generate-cert.sh
./nginx/ssl/generate-cert.sh
docker compose up -d --force-recreate nginxSymptoms: Docker Compose fails to start nginx or postgres because ports 80, 443, 8080, or 5432 are in use by local services (Apache, Nginx, local PostgreSQL).
Solution: Change the exposed host ports in your .env file without changing internal container wiring:
NGINX_PORT=8088
NGINX_SSL_PORT=8443
PORT=8081
DB_PORT=5433Then restart: docker compose up -d.
Symptoms: The Go API cannot connect to PostgreSQL on startup or during seeding.
Cause: Docker named volume postgres_data preserves the database credentials set when PostgreSQL was first initialized. If you changed DB_PASSWORD in .env after the container was already created, the database still uses the old password.
Solution: Reset the volume and recreate the database with your new .env settings:
# WARNING: This deletes existing local database data
docker compose down -v
docker compose up -d --build
# Re-run seeder:
docker run --rm --network bridge-network -v "$PWD":/app -w /app --env-file .env -e DB_HOST=postgres golang:1.25-alpine go run ./cmd/seedSymptoms: Registering a new staff member fails with {"detail": "hospital not found"}.
Cause: Database migrations create the tables, but the hospital table has no records until the seeder is executed.
Solution: Run the seeder to populate default hospital codes (HOSP001 - HOSP010):
docker run --rm --network bridge-network -v "$PWD":/app -w /app --env-file .env -e DB_HOST=postgres golang:1.25-alpine go run ./cmd/seedSymptoms: Key: 'CreateStaffRequest.Username' Error:Field validation for 'Username' failed on the 'alphanum' tag.
Solution: Usernames must be strictly alphanumeric English characters ([a-zA-Z0-9]). Underscores (_), dashes (-), spaces, and Thai characters are rejected. Use somchai or nurseann instead of nurse_ann.
Symptoms: Field validation for 'Password' failed on the 'password' tag.
Solution: Check that the password satisfies all security criteria:
- Length: 8 to 32 characters
- English characters and numbers only (no Thai characters or spaces)
- At least 1 lowercase letter (
a-z) - At least 1 uppercase letter (
A-Z) - At least 1 digit (
0-9) - At least 1 special character (
!@#$%^&*()-_=+[]{}|;:'",.<>/?~\`)
Symptoms: API container crashes on startup with [FATAL] Invalid configuration: jwt secret must be at least 32 characters long.
Solution: For cryptographic security, JWT_SECRET must contain 32 or more characters. Ensure your .env has:
JWT_SECRET=your_jwt_secret_key_at_least_32_characters_longSymptoms: Browser warns "Your connection is not private", or curl fails with SSL handshake error.
Solution: Because the certificate is self-signed for local development:
- curl: Pass the
-k(or--insecure) flag:curl -k https://localhost/health - Chrome / Edge: Click "Advanced" -> "Proceed to localhost (unsafe)".
- Firefox: Click "Advanced" -> "Accept the Risk and Continue".
Symptoms: Running ./nginx/ssl/generate-cert.sh on Windows fails with carriage return syntax errors.
Solution: Convert CRLF to LF line endings using Git Bash or dos2unix:
dos2unix ./nginx/ssl/generate-cert.sh
# Or run with sh directly:
sh ./nginx/ssl/generate-cert.sh