Make any microservice have a GraphQL implementation.
Live website: https://charles2ke.github.io/GraphQL/
This repository contains a minimal, working backend service that exposes a GraphQL
API for a small User / Post domain, plus an optional finance surface that
aggregates three upstream domains. It uses in-memory storage and mock connectors,
so it runs from a clean checkout without any database or other external
dependency.
git clone https://github.com/charles2ke/GraphQL.git
cd GraphQL
npm install
npm start # http://localhost:4000/graphql
npm test # node:test suite
Open http://localhost:4000/graphql in a browser to explore the schema in the Apollo Sandbox, or jump to Example queries.
@as-integrations/express5node:test runnersrc/
index.js # HTTP bootstrap: Express app + /graphql, /export, /graphql/stream
server.js # Apollo Server factory (reused by the tests)
schema.js # GraphQL type definitions and executable schema factory
resolvers.js # Query / Mutation / Subscription / field resolvers
cache/index.js # Pluggable TTL cache (memory, file, shared provider)
config/ # Environment-driven finance and CORS configuration
connectors/ # OpenTrading, Portfolio-Watcher, tax-break adapters + HTTP client
data/store.js # In-memory data store with seed data
domain/finance.js # Canonical finance models and normalization helpers
export/ # Excel (.xlsx) writer, dataset registry, and /export routes
observability/ # Structured logging, metrics, error classification, Apollo plugin
services/financeService.js # Finance aggregation, caching, and error handling
streaming/ # In-process pub/sub and the SSE streaming endpoint
validation/financeArgs.js # Shared finance argument validation
test/
graphql.test.js # API tests executed against the schema
finance.test.js # Finance service, filtering, and pagination tests
connectors.test.js # Connector selection and HTTP client tests
cors.test.js # CORS allow-list tests
export.test.js # Excel export writer, dataset, and route tests
observability.test.js # Logging, metrics, and error classification tests
resilience.test.js # Timeout, retry, and partial-failure tests
streaming.test.js # Pub/sub and Server-Sent Events streaming tests
website/
src/App.jsx # Learning site: primer, tips, API Explorer
src/backendSamples.js # GraphQL server samples in 10 backend languages
vite.config.js # Vite config (GitHub Pages base path + /graphql dev proxy)
.github/workflows/
ci.yml # Backend tests plus website lint/build on push and PRs
deploy-pages.yml # Builds website/ and publishes it to GitHub Pages
git clone https://github.com/charles2ke/GraphQL.git
cd GraphQL
npm install
npm start # or: npm run dev (restarts on file changes)
The server listens on port 4000 by default (override with the PORT
environment variable):
Opening the GraphQL endpoint in a browser loads the Apollo Sandbox, where you can explore the schema and run the operations below.
The React learning website includes a GraphQL primer, side-by-side server samples for ten backend languages, practical tips, and an interactive API Explorer that runs requests against this service.
The site is published with GitHub Pages at
https://charles2ke.github.io/GraphQL/. Every push to main is deployed by the
Deploy website to GitHub Pages workflow;
the workflow can also be run manually from the Actions tab. The workflow enables
Pages and forces its build type to GitHub Actions, so the built website — not the
Jekyll-rendered README — is what gets served. If the deployment ever fails to
update the Pages configuration, set Settings → Pages → Source to
GitHub Actions once and re-run the workflow.
cd website
npm install
npm run dev
Keep the API server running on port 4000, then open the URL printed by Vite
(normally http://localhost:5173). The API Explorer is available at
/#explorer. During development, Vite proxies /graphql requests to the local
API. To target a different API, start Vite with VITE_GRAPHQL_URL set to its
GraphQL endpoint.
To build the same static site that GitHub Pages deploys:
cd website
GITHUB_PAGES=true npm run build # output in website/dist
npm run preview # serve the production build locally
npm run lint # Oxlint
npm test
The CI workflow runs on every push to main, on
every pull request, and on demand from the Actions tab. It has two jobs:
npm ci then npm test (node:test) at the repository root.npm ci, npm run lint (Oxlint) and
GITHUB_PAGES=true npm run build inside website/, matching what the Pages
deployment builds.Runs are grouped per branch and superseded runs are cancelled automatically.
| Type | Operation | Description |
|---|---|---|
| Query | users |
List all users |
| Query | user(id: ID!) |
Fetch a single user, null when unknown |
| Query | posts |
List all posts |
| Query | post(id: ID!) |
Fetch a single post, null when unknown |
| Query | portfolioOverview(accountId, from, to, limit, offset) |
Fetch finance accounts, positions, snapshots, and P/L |
| Query | tradeHistory(accountId, symbol, side, status, from, to, limit, offset) |
Fetch trades/orders enriched with tax events |
| Query | taxEstimate(taxYear, accountId, symbol, from, to, limit, offset) |
Estimate tax from tax-relevant trading activity |
| Mutation | createUser(name, email) |
Create a user |
| Mutation | createPost(title, content, authorId) |
Create a post for an existing user |
| Subscription | userCreated |
Streams every newly created user |
| Subscription | postCreated(authorId) |
Streams new posts, optionally for one author |
The finance operations are documented in detail under Finance cluster integration; subscriptions are delivered over SSE, see Streaming.
List users with their posts:
query Users {
users {
id
name
email
posts {
id
title
}
}
}
Fetch one user:
query User {
user(id: "1") {
name
email
}
}
List posts with their author:
query Posts {
posts {
id
title
content
author {
id
name
}
}
}
mutation CreateUser {
createUser(name: "Grace Hopper", email: "grace@example.com") {
id
name
}
}
mutation CreatePost {
createPost(title: "Nanoseconds", content: "A talk about wire lengths.", authorId: "1") {
id
title
author {
name
}
}
}
Creating a post for an unknown authorId returns a BAD_USER_INPUT error.
curl http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"{ users { id name posts { title } } }"}'
Subscriptions can be streamed over Server-Sent Events at POST /graphql/stream
or GET /graphql/stream. GET is read-only streaming: it only accepts query
and subscription operations, taken from plain query/variables/
operationName query-string parameters, and is therefore safe to treat as a
“simple” cross-origin request. This is not the same payload contract as
/graphql — mutations sent with GET are rejected with 405 Method Not
Allowed so a mutating operation can never be triggered from a plain
cross-site navigation or <img>/<script> style request.
Mutations must be sent with POST, using a request shape that a simple
cross-origin form or link cannot forge: a Content-Type such as
application/json (which is not a CORS “simple” content type) and/or a
custom header (e.g. X-Requested-With), matching the CSRF-prevention shape
that Apollo Server enforces by default on /graphql. Requests that omit
this shape should be rejected before they reach resolvers.
SSE keeps the transport plain HTTP — no WebSocket upgrade or extra service is required.
# Stream new users as they are created (query/subscription only over GET)
curl -N -H 'Accept: text/event-stream' \
--get http://localhost:4000/graphql/stream \
--data-urlencode 'query=subscription { userCreated { id name email } }'
# Mutations must go to /graphql over POST with a non-simple Content-Type
curl -X POST http://localhost:4000/graphql \
-H 'Content-Type: application/json' \
-d '{"query":"mutation { createUser(name: \"Grace\", email: \"grace@example.com\") { id } }"}'
Each emitted result arrives as an event: next frame carrying the usual
GraphQL response body, and the stream finishes with event: complete.
Queries sent to /graphql/stream produce exactly one next frame followed
by complete. Comment frames (: ping) act as heartbeats so idle
connections survive proxies, and a subscription is torn down as soon as the
client disconnects.
Events are dispatched by an in-process pub/sub (src/streaming/pubsub.js).
It is intentionally dependency-free, which means subscribers only see events
published by their own instance; swap that module for a Redis/NATS-backed
implementation with the same publish/subscribe contract to run more than
one replica.
/graphql, /graphql/stream, and /export are served behind an explicit
origin allow-list — a wildcard (*) Access-Control-Allow-Origin is never
emitted — so that only trusted front-ends can read responses cross-origin.
The allow-list is built in src/config/cors.js from the
CORS_ALLOWED_ORIGINS environment variable and applied by src/index.js:
CORS_ALLOWED_ORIGINS='https://app.example.com,https://admin.example.com' npm start
import { loadCorsOptions } from './config/cors.js';
const corsOptions = loadCorsOptions();
app.use('/graphql', cors(corsOptions), /* ... */);
An allowed Origin is echoed back verbatim; any other origin receives no
CORS headers, so the browser blocks the response. When
CORS_ALLOWED_ORIGINS is unset every cross-origin read is denied — this is
the secure default, so the variable must be set for browser front-ends that
live on a different origin. Requests without an Origin header (curl,
server-to-server calls) are unaffected.
Any dataset the API serves can be downloaded as an Excel workbook (.xlsx) —
useful for sharing a portfolio snapshot or trade history with a spreadsheet.
The files are generated in-process, so no extra dependency or service is needed.
curl http://localhost:4000/export # list datasets
curl -O -J http://localhost:4000/export/trades.xlsx # download a workbook
| Dataset | Path | Sheets |
|---|---|---|
users |
/export/users.xlsx |
Users (with post counts) |
posts |
/export/posts.xlsx |
Posts (with author names) |
portfolio |
/export/portfolio.xlsx |
Accounts, Positions, Performance |
trades |
/export/trades.xlsx |
Trades, Orders, Tax Events |
tax-estimate |
/export/tax-estimate.xlsx?taxYear=2024 |
Summary, Tax Events |
Finance exports accept the same query parameters as their GraphQL counterparts
(accountId, symbol, side, status, from, to, limit, offset, and
taxYear, which is required for tax-estimate):
curl -O -J 'http://localhost:4000/export/trades.xlsx?accountId=acct-1&symbol=AAPL&limit=50'
When upstream connectors return partial data, the workbook gains an extra
Errors sheet describing each failure instead of hiding the gap.
This service exposes a unified finance GraphQL surface over three upstream domains:
The initial implementation uses mock connectors under src/connectors/ so the
API runs from a clean checkout. Each connector exposes a small async contract
that can be replaced later with HTTP, gRPC, queue, or database-backed clients
without changing the GraphQL schema.
The running server loads connector settings from the environment via
src/config/finance.js (the last three rows are read elsewhere but listed here
for a single view of the service configuration):
| Variable | Description | Default |
|---|---|---|
PORT |
HTTP port the service listens on | 4000 |
OPENTRADING_ENDPOINT |
OpenTrading endpoint placeholder | mock://opentrading |
OPENTRADING_API_KEY |
OpenTrading credential placeholder | empty |
PORTFOLIO_WATCHER_ENDPOINT |
Portfolio-Watcher endpoint placeholder | mock://portfolio-watcher |
PORTFOLIO_WATCHER_API_KEY |
Portfolio-Watcher credential placeholder | empty |
TAX_BREAK_ENDPOINT |
tax-break endpoint placeholder | mock://tax-break |
TAX_BREAK_API_KEY |
tax-break credential placeholder | empty |
FINANCE_CACHE_TTL_MS |
Resolver cache TTL | 1000 |
FINANCE_CACHE_STORE |
Cache strategy: memory, file (persistent), or shared |
memory |
FINANCE_CACHE_FILE |
Cache file used when FINANCE_CACHE_STORE=file |
.cache/finance-cache.json |
FINANCE_CACHE_SHARED_MODULE |
Optional shared cache provider module path used when FINANCE_CACHE_STORE=shared |
empty |
FINANCE_HTTP_TIMEOUT_MS |
Per-request upstream timeout | 5000 |
FINANCE_HTTP_MAX_RETRIES |
Retries for timeouts, 429s, and 5xx responses | 2 |
FINANCE_DEFAULT_PAGE_SIZE |
Default page size when limit is omitted |
25 |
FINANCE_MAX_PAGE_SIZE |
Upper bound applied to any requested limit |
100 |
LOG_LEVEL |
Structured log level (debug/info/warn/error) |
info |
CORS_ALLOWED_ORIGINS |
Comma-separated list of origins allowed to read /graphql, /graphql/stream, and /export cross-origin |
empty (all cross-origin reads denied) |
Each connector endpoint that is not a mock:// URL is served by the
production HTTP client in src/connectors/httpClient.js, which adds bearer
authentication, request timeouts, bounded retries with exponential backoff, and
per-call metrics. Mock adapters remain the default so the service still runs
from a clean checkout. Live endpoints require the corresponding *_API_KEY;
when credentials are missing, connectors fail safely with a non-sensitive auth
error and readiness reports degraded.
Do not commit real credentials. Production connectors should read credentials from environment variables or a secret manager and keep the same method names as the mock adapters.
financeService through the request context.financeService calls each upstream connector and normalizes inconsistent
field names in src/domain/finance.js.TaxEvent records.FinanceUpstreamError objects so clients
receive actionable source/code/message details while still getting any
partial data from healthy upstreams.FINANCE_CACHE_TTL_MS) that also de-duplicates concurrent requests, so
overlapping resolvers share a single connector call.Portfolio overview with positions and P/L:
query PortfolioOverview {
portfolioOverview {
accounts { id name provider currency }
positions { symbol quantity marketValue unrealizedPnL }
performance { asOf totalValue dayPnL totalPnL }
totalMarketValue
totalUnrealizedPnL
errors { source code message }
}
}
Trade history mapped to tax-relevant events:
query TradeHistory {
tradeHistory(symbol: "AAPL") {
trades { id side symbol quantity price executedAt }
taxEvents { tradeId proceeds costBasis realizedGain occurredAt }
errors { source code message }
}
}
Tax summary traceable to the underlying trading activity:
query TaxEstimate {
taxEstimate(taxYear: 2026) {
totalProceeds
totalCostBasis
realizedGain
estimatedTax
events { id tradeId realizedGain }
errors { source code message }
}
}
Finance queries accept optional filters and offset pagination:
portfolioOverview(accountId, from, to, limit, offset) — from/to bound
performance snapshots (inclusive ISO-8601); limit/offset page positions.tradeHistory(accountId, symbol, side, status, from, to, limit, offset) —
filters trades and orders, then pages them. Returned tax events always match
the trades on the current page.taxEstimate(taxYear, accountId, symbol, from, to, limit, offset) — totals are
always computed over every matching event; limit/offset only page the
returned events.Every finance payload includes pageInfo { totalCount limit offset hasNextPage
hasPreviousPage }. Requested limits are clamped to FINANCE_MAX_PAGE_SIZE.
Invalid date ranges (from > to) and malformed date/pagination inputs are
rejected with BAD_USER_INPUT and extensions.category = "validation".
query RecentSells {
tradeHistory(side: "SELL", from: "2026-01-01T00:00:00.000Z", limit: 10) {
trades { id symbol quantity price executedAt }
pageInfo { totalCount hasNextPage }
}
}
src/observability/logger.js, with
credential-like fields redacted. One line per GraphQL operation includes the
operation name, duration, outcome, and error codes.src/observability/metrics.js records GraphQL operation
counts/latency, connector call counts/latency per source and operation,
upstream retry failures, classified failures
(finance_upstream_errors_total{source,operation,category,retryable}), and
cache hit/miss/coalesced counters labelled with the active store. Scrape them
at GET /metrics.src/observability/errors.js maps every upstream
failure to a stable category (AUTH, RATE_LIMIT, TIMEOUT, NETWORK,
UPSTREAM_CLIENT_ERROR, UPSTREAM_SERVER_ERROR, UNKNOWN) plus status and
retryable. Those fields are returned on every payload’s
errors { source code category status retryable message }, so a partial
response still explains what failed and whether retrying helps.validation, auth, upstream, and
internal, with non-sensitive messages.GET /health is a liveness probe; GET /ready calls each
connector’s health check and returns 503 when any upstream is degraded.Upstream reads go through a TTL cache selected by FINANCE_CACHE_STORE
(src/cache/index.js):
memory (default): in-process, fastest, cleared on restart.file: the same TTL semantics mirrored to FINANCE_CACHE_FILE, so a
restarted process serves warm upstream data instead of refetching everything.shared: optional provider loaded from FINANCE_CACHE_SHARED_MODULE. The
module must export createSharedCacheStore() returning a store with
get(key), set(key, value, ttlMs), and clear() methods (Redis-like
adapters can implement this contract). If loading fails, the service logs a
warning and falls back to memory.Concurrent resolvers asking for the same key share one in-flight request, and payloads containing upstream errors are never cached so a transient outage is not pinned for the whole TTL.
Follow-up production tasks:
Data lives in memory only, so every restart resets the service to its seed data
(two users and two posts). Swapping src/data/store.js for a database-backed
implementation is enough to persist data — the resolvers receive the store through
the GraphQL context.
Report vulnerabilities as described in SECURITY.md. This project is licensed under the Apache License 2.0.