Skip to content

Commit 0cecc8f

Browse files
committed
refactor(provider): extract gnews adapter
1 parent 245ed65 commit 0cecc8f

5 files changed

Lines changed: 82 additions & 63 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
- Docker healthcheck now honors the runtime `PORT` environment variable.
2323
- Versioned `/api/v1/*` endpoints with `{ data, meta }` response envelopes, cache metadata, and structured error bodies.
2424

25+
### Changed
26+
27+
- Split GNews-specific provider mapping and payload validation into a provider adapter, leaving the news service focused on cache/search orchestration.
28+
2529
### Security
2630

2731
- Refreshed the dependency lockfile and upgraded OpenTelemetry packages; `npm audit --omit=dev` reports zero vulnerabilities.

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ Legacy errors: `{ "error": "message" }`. Versioned `/api/v1/*` errors: `{ "error
146146
- `src/http/responses.ts` — Versioned response envelope helpers.
147147
- `src/cache/store.ts` — Pluggable cache: memory or Redis.
148148
- `src/metrics/register.ts` — Prometheus registry: HTTP, cache, and upstream provider metrics.
149-
- `src/services/newsService.ts` — GNews client, normalized cache keys, cache resilience/coalescing, timeouts, upstream instrumentation.
149+
- `src/providers/gnewsProvider.ts` — GNews adapter: provider params, payload validation, timeouts, upstream instrumentation.
150+
- `src/services/newsService.ts` — Article search orchestration: normalized cache keys, cache resilience/coalescing, title/source narrowing.
150151
- `test/` — Vitest; HTTP tests mock `axios` (no live GNews in CI).
151152

152153
Architecture diagram: [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). Release notes: [CHANGELOG.md](CHANGELOG.md).

docs/ARCHITECTURE.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ flowchart LR
99
Routes --> Controllers
1010
Controllers --> NewsService
1111
NewsService --> Cache
12-
NewsService --> GNews["GNews API"]
12+
NewsService --> Provider["GNews provider adapter"]
13+
Provider --> GNews["GNews API"]
1314
```
1415

1516
1. **Process**`dotenv` loads first; **`otel-bootstrap`** starts OpenTelemetry when an OTLP endpoint (or `OTEL_TRACING_ENABLED=1`) is configured, before Express loads so HTTP is instrumented.
1617
2. **Express** (`src/app.ts`) applies middleware in order: trust-proxy (optional), **Pino** request logging, **metrics** observer, **Helmet**, JSON body parser, **rate limiting** (skips `/health`, `/ready`, `/openapi.yaml`, `/metrics`), then mounts `/api` routes.
1718
3. **Controllers** validate query parameters and map domain results to HTTP status codes.
18-
4. **News service** builds cache keys from normalized search parameters (`query`, `count`, `lang`, `country`, `from`, `to`, `sortBy`), reads through `getCacheStore()` (in-memory or **Redis** when `REDIS_URL` is set), coalesces identical in-flight misses per process, and otherwise calls GNews `/api/v4/search` via `axios`. Cache backend errors are logged and metriced without failing the article request.
19+
4. **News service** builds cache keys from normalized search parameters (`query`, `count`, `lang`, `country`, `from`, `to`, `sortBy`), reads through `getCacheStore()` (in-memory or **Redis** when `REDIS_URL` is set), coalesces identical in-flight misses per process, and delegates upstream fetches to the GNews provider adapter. Cache backend errors are logged and metriced without failing the article request.
1920
5. **Response mapping** keeps legacy `/api/articles*` endpoints backward compatible with raw arrays, while `/api/v1/*` returns `{ data, meta }` envelopes with `requestId`, normalized filters, and cache status.
2021
6. **Title** and **source** endpoints reuse the search call, then narrow results in memory (exact title match; case-insensitive source name match).
2122

@@ -41,8 +42,8 @@ sequenceDiagram
4142
Cache-->>Svc: articles
4243
else miss (read fail falls through here too)
4344
Svc->>Svc: coalesce identical in-flight misses
44-
Svc->>GNews: GET /api/v4/search (timeout)
45-
GNews-->>Svc: articles (502 on provider/transport error)
45+
Svc->>GNews: provider adapter: GET /api/v4/search (timeout)
46+
GNews-->>Svc: normalized articles (502 on provider/transport error)
4647
Svc->>Cache: set(key, articles, TTL 600s)
4748
end
4849
Svc-->>API: articles

src/providers/gnewsProvider.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import axios from "axios";
2+
import { UPSTREAM_BASE_URL, UPSTREAM_TIMEOUT_MS } from "../config/upstream";
3+
import { HttpError } from "../errors/HttpError";
4+
import {
5+
upstreamRequestDurationSeconds,
6+
upstreamRequestsTotal,
7+
} from "../metrics/register";
8+
import { Article } from "../types/article";
9+
import { ArticleSearchOptions } from "../types/search";
10+
11+
export interface NewsProvider {
12+
search(options: ArticleSearchOptions): Promise<Article[]>;
13+
}
14+
15+
function normalizeArticles(data: unknown): Article[] {
16+
if (
17+
data === null ||
18+
typeof data !== "object" ||
19+
!("articles" in data) ||
20+
!Array.isArray((data as { articles: unknown }).articles)
21+
) {
22+
throw new HttpError(502, "Invalid response from news provider", "invalid_provider_payload");
23+
}
24+
return (data as { articles: Article[] }).articles;
25+
}
26+
27+
function toProviderParams(options: ArticleSearchOptions): Record<string, string | number | undefined> {
28+
return {
29+
q: options.query,
30+
max: options.count,
31+
token: process.env.GNEWS_API_KEY,
32+
lang: options.lang,
33+
country: options.country,
34+
from: options.from,
35+
to: options.to,
36+
sortby: options.sortBy,
37+
};
38+
}
39+
40+
export class GNewsProvider implements NewsProvider {
41+
async search(options: ArticleSearchOptions): Promise<Article[]> {
42+
const stopUpstreamTimer = upstreamRequestDurationSeconds.startTimer();
43+
44+
try {
45+
const response = await axios.get<{ articles: Article[] }>(`${UPSTREAM_BASE_URL}/search`, {
46+
params: toProviderParams(options),
47+
timeout: UPSTREAM_TIMEOUT_MS,
48+
validateStatus: (s) => s >= 200 && s < 300,
49+
});
50+
51+
const articles = normalizeArticles(response.data);
52+
upstreamRequestsTotal.inc({ outcome: "success" });
53+
stopUpstreamTimer({ outcome: "success" });
54+
return articles;
55+
} catch (err) {
56+
const outcome =
57+
err instanceof HttpError && err.statusCode === 502 ? "invalid_payload" : "error";
58+
upstreamRequestsTotal.inc({ outcome });
59+
stopUpstreamTimer({ outcome });
60+
if (axios.isAxiosError(err)) {
61+
throw new HttpError(502, "Upstream news service unavailable", "upstream_unavailable");
62+
}
63+
throw err;
64+
}
65+
}
66+
}
67+
68+
export const newsProvider: NewsProvider = new GNewsProvider();

src/services/newsService.ts

Lines changed: 3 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,10 @@
1-
import axios from "axios";
21
import { Article } from "../types/article";
3-
import { HttpError } from "../errors/HttpError";
42
import { CacheStore, getCacheStore } from "../cache/store";
5-
import { UPSTREAM_BASE_URL, UPSTREAM_TIMEOUT_MS } from "../config/upstream";
63
import { ArticleSearchFilters, ArticleSearchOptions } from "../types/search";
7-
import {
8-
cacheErrorsTotal,
9-
cacheEventsTotal,
10-
upstreamRequestDurationSeconds,
11-
upstreamRequestsTotal,
12-
} from "../metrics/register";
4+
import { cacheErrorsTotal, cacheEventsTotal } from "../metrics/register";
135
import { logger } from "../logger";
6+
import { newsProvider } from "../providers/gnewsProvider";
147

15-
const API_KEY = process.env.GNEWS_API_KEY;
168
const inFlightSearches = new Map<string, Promise<ArticleSearchResult>>();
179

1810
export type ArticleSearchCacheStatus = "hit" | "miss" | "coalesced";
@@ -22,18 +14,6 @@ export interface ArticleSearchResult {
2214
cache: ArticleSearchCacheStatus;
2315
}
2416

25-
function normalizeArticles(data: unknown): Article[] {
26-
if (
27-
data === null ||
28-
typeof data !== "object" ||
29-
!("articles" in data) ||
30-
!Array.isArray((data as { articles: unknown }).articles)
31-
) {
32-
throw new HttpError(502, "Invalid response from news provider", "invalid_provider_payload");
33-
}
34-
return (data as { articles: Article[] }).articles;
35-
}
36-
3717
function searchCacheKey(options: ArticleSearchOptions): string {
3818
return JSON.stringify({
3919
query: options.query,
@@ -46,19 +26,6 @@ function searchCacheKey(options: ArticleSearchOptions): string {
4626
});
4727
}
4828

49-
function toProviderParams(options: ArticleSearchOptions): Record<string, string | number | undefined> {
50-
return {
51-
q: options.query,
52-
max: options.count,
53-
token: API_KEY,
54-
lang: options.lang,
55-
country: options.country,
56-
from: options.from,
57-
to: options.to,
58-
sortby: options.sortBy,
59-
};
60-
}
61-
6229
async function readCachedArticles(
6330
store: CacheStore,
6431
cacheKey: string
@@ -98,29 +65,7 @@ async function fetchArticlesFromUpstream(
9865
store: CacheStore,
9966
cacheKey: string
10067
): Promise<ArticleSearchResult> {
101-
const stopUpstreamTimer = upstreamRequestDurationSeconds.startTimer();
102-
let articles: Article[];
103-
try {
104-
const response = await axios.get<{ articles: Article[] }>(`${UPSTREAM_BASE_URL}/search`, {
105-
params: toProviderParams(options),
106-
timeout: UPSTREAM_TIMEOUT_MS,
107-
validateStatus: (s) => s >= 200 && s < 300,
108-
});
109-
110-
articles = normalizeArticles(response.data);
111-
upstreamRequestsTotal.inc({ outcome: "success" });
112-
stopUpstreamTimer({ outcome: "success" });
113-
} catch (err) {
114-
const outcome =
115-
err instanceof HttpError && err.statusCode === 502 ? "invalid_payload" : "error";
116-
upstreamRequestsTotal.inc({ outcome });
117-
stopUpstreamTimer({ outcome });
118-
if (axios.isAxiosError(err)) {
119-
throw new HttpError(502, "Upstream news service unavailable", "upstream_unavailable");
120-
}
121-
throw err;
122-
}
123-
68+
const articles = await newsProvider.search(options);
12469
await writeCachedArticles(store, cacheKey, articles);
12570
return { articles, cache: "miss" };
12671
}

0 commit comments

Comments
 (0)