Skip to content

Commit 07a171e

Browse files
committed
feat: allow configurable news upstream
1 parent c7a95f3 commit 07a171e

7 files changed

Lines changed: 33 additions & 4 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ PORT=3000
66
# Optional operations (see docs/OPERATIONS.md)
77
# NODE_ENV=production
88
# LOG_LEVEL=info
9+
# GNEWS_BASE_URL=https://gnews.io/api/v4
910
# HTTP_TIMEOUT_MS=15000
1011
# SHUTDOWN_TIMEOUT_MS=10000
1112
# RATE_LIMIT_MAX=120

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1212
- Optional search filters for `lang`, `country`, `from`, `to`, and `sortBy`, with validation and normalized cache keys.
1313
- Prometheus metrics for article-search cache hit/miss behavior and upstream provider request latency/outcomes.
1414
- Curl-based `npm run smoke` check for a running instance.
15+
- Configurable `GNEWS_BASE_URL` for deterministic local integration tests and benchmarks.
1516

1617
### Security
1718

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# news-api
22

3-
**Express + TypeScript** service that searches news through the [GNews API](https://gnews.io/). Searches support provider-backed filters for language, country, date range, and sort order. Identical normalized searches are cached (in-memory by default, or **Redis** when `REDIS_URL` is set) to protect quota and latency.
3+
**Express + TypeScript** service that searches news through the [GNews API](https://gnews.io/). Searches support provider-backed filters for language, country, date range, and sort order. Identical normalized searches are cached (in-memory by default, or **Redis** when `REDIS_URL` is set) to protect quota and latency. The upstream base URL is injectable for deterministic local integration tests and benchmarks.
44

55
## Production-oriented features
66

docs/OPERATIONS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
| `PORT` | `3000` | HTTP listen port. |
99
| `NODE_ENV` || Use `production` in deployed environments. |
1010
| `LOG_LEVEL` | `info` (non-test) | Pino level (`trace``fatal`, or `silent`). |
11+
| `GNEWS_BASE_URL` | `https://gnews.io/api/v4` | Upstream provider base URL. Override only for local integration tests, benchmarks, or compatible provider mocks. |
1112
| `HTTP_TIMEOUT_MS` | `15000` | Outbound GNews request timeout (max `60000`). |
1213
| `SHUTDOWN_TIMEOUT_MS` | `10000` | Force-exit if `server.close` does not finish. |
1314
| `RATE_LIMIT_MAX` | `120` | Max requests per IP per window. |

src/config/upstream.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,15 @@ const raw = Number(process.env.HTTP_TIMEOUT_MS ?? 15_000);
22

33
export const UPSTREAM_TIMEOUT_MS =
44
Number.isFinite(raw) && raw > 0 ? Math.min(raw, 60_000) : 15_000;
5+
6+
const DEFAULT_UPSTREAM_BASE_URL = "https://gnews.io/api/v4";
7+
8+
export function resolveUpstreamBaseUrl(rawUrl = process.env.GNEWS_BASE_URL): string {
9+
const value = rawUrl?.trim();
10+
if (!value) {
11+
return DEFAULT_UPSTREAM_BASE_URL;
12+
}
13+
return value.replace(/\/+$/, "");
14+
}
15+
16+
export const UPSTREAM_BASE_URL = resolveUpstreamBaseUrl();

src/services/newsService.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import axios from "axios";
22
import { Article } from "../types/article";
33
import { HttpError } from "../errors/HttpError";
44
import { getCacheStore } from "../cache/store";
5-
import { UPSTREAM_TIMEOUT_MS } from "../config/upstream";
5+
import { UPSTREAM_BASE_URL, UPSTREAM_TIMEOUT_MS } from "../config/upstream";
66
import { ArticleSearchFilters, ArticleSearchOptions } from "../types/search";
77
import {
88
cacheEventsTotal,
@@ -11,7 +11,6 @@ import {
1111
} from "../metrics/register";
1212

1313
const API_KEY = process.env.GNEWS_API_KEY;
14-
const BASE_URL = "https://gnews.io/api/v4";
1514

1615
function normalizeArticles(data: unknown): Article[] {
1716
if (
@@ -63,7 +62,7 @@ export const fetchArticles = async (options: ArticleSearchOptions): Promise<Arti
6362
const stopUpstreamTimer = upstreamRequestDurationSeconds.startTimer();
6463
let articles: Article[];
6564
try {
66-
const response = await axios.get<{ articles: Article[] }>(`${BASE_URL}/search`, {
65+
const response = await axios.get<{ articles: Article[] }>(`${UPSTREAM_BASE_URL}/search`, {
6766
params: toProviderParams(options),
6867
timeout: UPSTREAM_TIMEOUT_MS,
6968
validateStatus: (s) => s >= 200 && s < 300,

test/upstream.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { describe, expect, it } from "vitest";
2+
import { resolveUpstreamBaseUrl } from "../src/config/upstream";
3+
4+
describe("resolveUpstreamBaseUrl", () => {
5+
it("defaults to GNews", () => {
6+
expect(resolveUpstreamBaseUrl("")).toBe("https://gnews.io/api/v4");
7+
expect(resolveUpstreamBaseUrl(undefined)).toBe("https://gnews.io/api/v4");
8+
});
9+
10+
it("trims trailing slashes from custom upstreams", () => {
11+
expect(resolveUpstreamBaseUrl(" http://127.0.0.1:4000/api/v4/// ")).toBe(
12+
"http://127.0.0.1:4000/api/v4"
13+
);
14+
});
15+
});

0 commit comments

Comments
 (0)