Skip to content

Commit 9d2c613

Browse files
committed
refactor(api): split integration-host.ts into focused modules
Extract three concerns out of the 1518-line integration-host.ts into dedicated modules, leaving the host as a thin façade: - integration-clients.ts: createHttpClient, createCacheClient, createLiveStoreClient, createLogger (pure context client factories) - integration-config.ts: resolveConfigWithSources, resolveConfig, warnInvalidConfig, ConfigSource/ConfigValueWithSource types - integration-routes.ts: mini-router machinery — registerIntegrationRoute, resetIntegrationRoutes, registerIntegrationRouteDispatcher + state Zero behavior change. resolveConfigWithSources re-exported from host so routes/admin.ts and its test remain byte-for-byte unchanged. registerIntegrationRouteDispatcher takes an explicit integrations Map parameter to avoid a static import cycle. Plan 002 characterization tests (16/16) pass unmodified.
1 parent 4edc975 commit 9d2c613

4 files changed

Lines changed: 484 additions & 453 deletions

File tree

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import type {
2+
CacheClient,
3+
HttpClient,
4+
HttpClientOptions,
5+
LiveStoreClient,
6+
Logger,
7+
} from "@openmapx/integration-framework";
8+
import type { FastifyInstance } from "fastify";
9+
import { redis } from "./redis";
10+
import { httpCacheKey } from "./utils/http-cache-key";
11+
import { createIntegrationLogger } from "./utils/integration-logger";
12+
13+
export function createHttpClient(_log: Logger): HttpClient {
14+
return {
15+
async get<T>(url: string, options?: HttpClientOptions): Promise<T> {
16+
const u = new URL(url);
17+
if (options?.params) {
18+
for (const [k, v] of Object.entries(options.params)) {
19+
if (v !== undefined) u.searchParams.set(k, String(v));
20+
}
21+
}
22+
23+
if (options?.cache?.ttl && redis) {
24+
const cacheKey = httpCacheKey(u.toString(), options?.headers);
25+
try {
26+
const cached = await redis.get(cacheKey);
27+
if (cached) return JSON.parse(cached) as T;
28+
} catch {
29+
// cache miss
30+
}
31+
32+
const res = await fetch(u.toString(), { headers: options?.headers });
33+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
34+
const data = (await res.json()) as T;
35+
redis.setex(cacheKey, options.cache.ttl, JSON.stringify(data)).catch(() => {});
36+
return data;
37+
}
38+
39+
const res = await fetch(u.toString(), { headers: options?.headers });
40+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
41+
return (await res.json()) as T;
42+
},
43+
44+
async post<T>(url: string, body?: unknown, options?: HttpClientOptions): Promise<T> {
45+
const res = await fetch(url, {
46+
method: "POST",
47+
headers: { "Content-Type": "application/json", ...options?.headers },
48+
body: body ? JSON.stringify(body) : undefined,
49+
});
50+
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
51+
return (await res.json()) as T;
52+
},
53+
};
54+
}
55+
56+
export function createCacheClient(prefix: string): CacheClient {
57+
return {
58+
async get<T>(key: string): Promise<T | null> {
59+
if (!redis) return null;
60+
const val = await redis.get(`int:${prefix}:${key}`);
61+
return val ? (JSON.parse(val) as T) : null;
62+
},
63+
async set(key: string, value: unknown, ttlSeconds?: number): Promise<void> {
64+
if (!redis) return;
65+
const k = `int:${prefix}:${key}`;
66+
if (ttlSeconds) {
67+
await redis.setex(k, ttlSeconds, JSON.stringify(value));
68+
} else {
69+
await redis.set(k, JSON.stringify(value));
70+
}
71+
},
72+
async del(key: string): Promise<void> {
73+
if (!redis) return;
74+
await redis.del(`int:${prefix}:${key}`);
75+
},
76+
async withCache<T>(key: string, ttlSeconds: number, fn: () => Promise<T>): Promise<T> {
77+
if (redis) {
78+
const k = `int:${prefix}:${key}`;
79+
try {
80+
const cached = await redis.get(k);
81+
if (cached) return JSON.parse(cached) as T;
82+
} catch {
83+
// cache miss
84+
}
85+
const result = await fn();
86+
redis.setex(k, ttlSeconds, JSON.stringify(result)).catch(() => {});
87+
return result;
88+
}
89+
return fn();
90+
},
91+
};
92+
}
93+
94+
/**
95+
* Reader for the cross-process `poi:live:<sourceId>` keyspace that
96+
* `services/data-manager`'s `write-live` stage populates. The keys are
97+
* deliberately NOT integration-namespaced — data-manager has no notion of
98+
* integration ids, only the source ids in `@openmapx/poi-source-registry`.
99+
* Prefixing here would silently miss every write.
100+
*
101+
* Process-scoped (one client shared across all integrations); per-key
102+
* isolation already happens via `@openmapx/poi-source-registry` ensuring
103+
* source ids are globally unique.
104+
*/
105+
export function createLiveStoreClient(): LiveStoreClient {
106+
return {
107+
async hmget<T>(key: string, fields: readonly string[]): Promise<(T | null)[]> {
108+
if (!redis) return fields.map(() => null);
109+
if (fields.length === 0) return [];
110+
const values = await redis.hmget(key, ...fields);
111+
return values.map((v) => (v ? (JSON.parse(v) as T) : null));
112+
},
113+
};
114+
}
115+
116+
export function createLogger(integrationId: string, fastify: FastifyInstance): Logger {
117+
return createIntegrationLogger(integrationId, fastify);
118+
}

apps/api/src/integration-config.ts

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import { existsSync, readFileSync } from "node:fs";
2+
import { join } from "node:path";
3+
import type { IntegrationManifest } from "@openmapx/integration-framework";
4+
import { eq } from "drizzle-orm";
5+
import { db } from "./db";
6+
import { integrationConfig } from "./db/schema";
7+
import { resolveVaultSecrets } from "./services/secrets";
8+
9+
export type ConfigSource = "default" | "database" | "vault" | "config.json" | "env";
10+
11+
export interface ConfigValueWithSource {
12+
value: unknown;
13+
source: ConfigSource;
14+
}
15+
16+
export async function resolveConfigWithSources(
17+
manifest: IntegrationManifest,
18+
directory: string,
19+
): Promise<Record<string, ConfigValueWithSource>> {
20+
const result: Record<string, ConfigValueWithSource> = {};
21+
const schema = manifest.configSchema as Record<string, unknown> | undefined;
22+
const knownKeys = new Set<string>();
23+
// Uppercased config key → canonical (original-case) key. Used to match env
24+
// vars like `INTEGRATION_PHOTOS_FLICKR_APIKEY` against configSchema key
25+
// `apiKey` without forcing operators to lowercase the suffix (or forcing
26+
// schema authors to pick all-lowercase keys).
27+
const upperToKey = new Map<string, string>();
28+
29+
if (schema) {
30+
const props = (schema.properties ?? schema) as Record<string, { default?: unknown }>;
31+
for (const [key, def] of Object.entries(props)) {
32+
if (key === "type" || key === "properties") continue;
33+
knownKeys.add(key);
34+
upperToKey.set(key.toUpperCase(), key);
35+
if (def && typeof def === "object" && "default" in def && def.default !== undefined) {
36+
result[key] = { value: def.default, source: "default" };
37+
}
38+
}
39+
}
40+
41+
if (knownKeys.size === 0) return result;
42+
43+
try {
44+
const [row] = await db
45+
.select({ config: integrationConfig.config })
46+
.from(integrationConfig)
47+
.where(eq(integrationConfig.integrationId, manifest.id))
48+
.limit(1);
49+
if (row?.config && typeof row.config === "object") {
50+
for (const [key, value] of Object.entries(row.config as Record<string, unknown>)) {
51+
if (knownKeys.has(key)) result[key] = { value, source: "database" };
52+
}
53+
}
54+
} catch {
55+
// DB not available
56+
}
57+
58+
// 3. Apply vault secrets
59+
try {
60+
const vaultSecrets = await resolveVaultSecrets(manifest.id);
61+
for (const [key, value] of Object.entries(vaultSecrets)) {
62+
if (knownKeys.has(key)) result[key] = { value, source: "vault" };
63+
}
64+
} catch {
65+
// vault unavailable
66+
}
67+
68+
const configJsonPath = join(directory, "config.json");
69+
if (existsSync(configJsonPath)) {
70+
try {
71+
const fileConfig = JSON.parse(readFileSync(configJsonPath, "utf-8"));
72+
if (typeof fileConfig === "object" && fileConfig !== null) {
73+
for (const [key, value] of Object.entries(fileConfig as Record<string, unknown>)) {
74+
if (knownKeys.has(key)) result[key] = { value, source: "config.json" };
75+
}
76+
}
77+
} catch {
78+
// ignore
79+
}
80+
}
81+
82+
// Env layer — highest priority. Pattern: `INTEGRATION_<ID>_<KEY>` (upper-cased
83+
// id with hyphens replaced by underscores, then the upper-cased config key).
84+
// Matching is case-insensitive on the configSchema key so both snake_case
85+
// and camelCase keys work (`apiKey` matches `INTEGRATION_X_APIKEY`).
86+
const prefix = `INTEGRATION_${manifest.id.replace(/-/g, "_").toUpperCase()}_`;
87+
for (const [envKey, envVal] of Object.entries(process.env)) {
88+
if (envVal === undefined) continue;
89+
if (!envKey.startsWith(prefix)) continue;
90+
const rest = envKey.slice(prefix.length);
91+
const canonical = upperToKey.get(rest);
92+
if (canonical) result[canonical] = { value: envVal, source: "env" };
93+
}
94+
95+
return result;
96+
}
97+
98+
export async function resolveConfig(
99+
manifest: {
100+
id: string;
101+
configSchema?: Record<string, unknown>;
102+
},
103+
directory: string,
104+
): Promise<Record<string, unknown>> {
105+
const withSources = await resolveConfigWithSources(manifest as IntegrationManifest, directory);
106+
const config: Record<string, unknown> = {};
107+
for (const [key, entry] of Object.entries(withSources)) {
108+
config[key] = entry.value;
109+
}
110+
return config;
111+
}
112+
113+
/**
114+
* Emits advisory warnings for required/enum config keys that violate the
115+
* manifest's `configSchema`. Never blocks load. Shared by cold start and reload.
116+
*/
117+
export function warnInvalidConfig(
118+
manifest: IntegrationManifest,
119+
config: Record<string, unknown>,
120+
id: string,
121+
warn: (msg: string) => void,
122+
): void {
123+
const configSchema = manifest.configSchema as Record<string, unknown> | undefined;
124+
if (!configSchema?.properties) return;
125+
const props = configSchema.properties as Record<
126+
string,
127+
{ type?: string; enum?: unknown[]; required?: boolean }
128+
>;
129+
for (const [key, def] of Object.entries(props)) {
130+
if (def.required && config[key] === undefined) {
131+
warn(`Integration ${id}: missing required config key "${key}"`);
132+
}
133+
if (def.enum && config[key] !== undefined && !def.enum.includes(config[key])) {
134+
warn(
135+
`Integration ${id}: config "${key}" value "${config[key]}" not in allowed values: ${def.enum.join(", ")}`,
136+
);
137+
}
138+
}
139+
}

0 commit comments

Comments
 (0)