Node.js REST API for multi-tenant document search with Elasticsearch, Redis caching, Redis rate limiting, structured logs, and Prometheus metrics.
Default local API base URL: http://localhost:3020
- Public health and metrics endpoints
- Authenticated document and search APIs
- Tenant isolation enforced on every protected request
- Reader/writer role separation
- Search caching and document caching in Redis
- Rate limiting backed by Redis
- Safe Elasticsearch query construction
- Soft delete behavior
- Reviewer-friendly demo and load-test scripts
cp .env.example .env
npm install
docker compose up -d elasticsearch redis
npm startThen check health:
curl --max-time 5 http://localhost:3020/health
curl --max-time 5 http://localhost:3020/metricsdocker compose up --buildThe API is exposed on http://localhost:3020 even when the container listens on port 3000 internally.
The application reads the following values from .env:
PORT=3020ELASTICSEARCH_URL=http://localhost:9200REDIS_URL=redis://localhost:6379ELASTICSEARCH_INDEX_PREFIX=documentsLOG_LEVEL=infoSEARCH_CACHE_TTL_SECONDS=60SEARCH_QUERY_MAX_LENGTH=200SEARCH_DEFAULT_PAGE=1SEARCH_DEFAULT_SIZE=10SEARCH_MAX_SIZE=50DOCUMENT_CACHE_TTL_SECONDS=300TENANT_RATE_LIMIT_PER_MINUTE=100DOCUMENT_RATE_LIMIT_PER_MINUTE=30RATE_LIMIT_WINDOW_SECONDS=60HEALTH_CHECK_TIMEOUT_MS=2000ELASTICSEARCH_REQUEST_TIMEOUT_MS=2000REDIS_CONNECT_TIMEOUT_MS=2000
Protected endpoints require both headers:
Authorization: Bearer <token>
X-Tenant-Id: <tenantId>Available prototype tokens:
tenant-a-reader-tokentenant-a-writer-tokentenant-b-reader-tokentenant-b-writer-token
Role behavior:
reader:GET /search,GET /documents/:idwriter:POST /documents,GET /search,GET /documents/:id,DELETE /documents/:id
curl -i http://localhost:3020/healthcurl -i http://localhost:3020/metricscurl -sS -X POST http://localhost:3020/documents \
-H "Content-Type: application/json" \
-H "Authorization: Bearer tenant-a-writer-token" \
-H "X-Tenant-Id: tenant-a" \
-d '{
"title": "Contract Renewal Policy",
"content": "The contract renewal process begins 60 days before expiry.",
"metadata": {
"department": "legal",
"category": "contracts"
}
}'curl -sS "http://localhost:3020/search?q=contract&page=1&size=10" \
-H "Authorization: Bearer tenant-a-reader-token" \
-H "X-Tenant-Id: tenant-a"curl -sS http://localhost:3020/documents/<id> \
-H "Authorization: Bearer tenant-a-reader-token" \
-H "X-Tenant-Id: tenant-a"curl -sS -X DELETE http://localhost:3020/documents/<id> \
-H "Authorization: Bearer tenant-a-writer-token" \
-H "X-Tenant-Id: tenant-a"Seed repeatable demo content for both tenants:
npm run seedThe seed script uses the real HTTP API, prints created ids, and exits non-zero if any document fails.
Run a local concurrency test against search:
npm run load-testOptional document GET test:
DOCUMENT_ID=<id> npm run load-testUseful overrides:
API_BASE_URL=http://localhost:3020TENANT_ID=tenant-aTOKEN=tenant-a-reader-tokenSEARCH_QUERY=contractCONNECTIONS=50DURATION_SECONDS=30
- The default tenant rate limit is
100requests/minute. autocannoncan exceed that window quickly even with lowCONNECTIONS.429responses during load tests are expected unless local limits are raised.- For raw latency measurement, temporarily raise:
TENANT_RATE_LIMIT_PER_MINUTEDOCUMENT_RATE_LIMIT_PER_MINUTE
- For abuse-protection verification, keep or lower limits and observe
RATE_LIMITEDresponses. - Local load tests demonstrate methodology and baseline behavior, not 10M-document production scale.
Quick review path:
- Local functional checks cover
/health,/metrics, auth, tenant isolation, create/search/get/delete, and soft delete. npm run seedloads repeatable demo data for both tenants without destructive cleanup.- Cache checks verify
X-Cache: MISSandX-Cache: HITfor search and document GET. - Rate-limit checks verify
429 RATE_LIMITEDandrate_limited_total. - Observability checks verify structured logs,
requestId, and the expected metric families. npm run load-testprovides a local concurrency baseline withautocannon.- Production-scale strategy and pass/fail criteria are documented in Testing Strategy.
The local prototype exposes Prometheus text metrics at:
GET /metricsExample:
curl http://localhost:3020/metricsCustom metric families:
http_requests_totalhttp_request_duration_secondscache_hits_totalcache_misses_totalrate_limited_totaldocuments_indexed_totaldocuments_deleted_totalsearch_requests_totalsearch_duration_seconds
Label safety:
- Metrics avoid raw search query text.
- Metrics avoid document IDs.
- Metrics avoid request IDs.
- Metrics avoid Authorization headers or bearer tokens.
- Metrics avoid Redis cache keys.
- HTTP route labels use low-cardinality route patterns such as
/documents/:id.
Design notes and operational assumptions are documented here:
.
├── docker-compose.yml
├── Dockerfile
├── docs/
├── scripts/
├── src/
└── package.json
/healthis public and should report healthy when dependencies are available./metricsis public and returns Prometheus-formatted metrics.- Cache behavior uses
X-Cache: HITandX-Cache: MISS. - Tenant mismatches are rejected by the auth layer.
- Logs are structured and should not leak bearer tokens or document content.
-
docker compose up --build -
curl /health -
curl /metrics -
npm run seed - Search tenant-a seeded data
- Search tenant-b seeded data
- Confirm tenant-a data does not appear for tenant-b
- Reader
POST /documentsreturns403 - Writer
POST /documentsreturns201 - Repeated
GET /documents/:idshowsX-Cache: MISSthenX-Cache: HIT - Repeated
GET /searchshowsX-Cache: MISSthenX-Cache: HIT - Invalid search returns
400 VALIDATION_ERROR -
DELETE /documents/:idreturnsSOFT_DELETED -
GETdeleted document returns404 -
npm run load-testcompletes -
/metricscounters change - Logs include
requestId - Logs do not include
Authorizationheaders or bearer tokens
AI tools were used to assist with architecture brainstorming, implementation scaffolding, documentation organization, and test planning. Final design decisions, code review, and validation were performed by me.