From b5c519969b613396bafc177cd2b1f5e8250ee7b1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 10:13:42 +0530 Subject: [PATCH 1/2] feat: support env var substitution in API server URLs Resolve ${VAR} syntax in chronicle.yaml server URLs at runtime, enabling single build deployed across multiple environments. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/lib/env.ts | 9 +++++++++ packages/chronicle/src/server/api/apis-proxy.ts | 3 ++- packages/chronicle/src/server/utils/api-markdown.ts | 3 ++- 3 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 packages/chronicle/src/lib/env.ts diff --git a/packages/chronicle/src/lib/env.ts b/packages/chronicle/src/lib/env.ts new file mode 100644 index 0000000..50682de --- /dev/null +++ b/packages/chronicle/src/lib/env.ts @@ -0,0 +1,9 @@ +export function substituteEnvVars(value: string): string { + return value.replace(/\$\{(\w+)\}/g, (_, name) => { + const val = process.env[name]; + if (val === undefined) { + throw new Error(`Environment variable '${name}' is not set`); + } + return val; + }); +} diff --git a/packages/chronicle/src/server/api/apis-proxy.ts b/packages/chronicle/src/server/api/apis-proxy.ts index eb4a3ba..1d2b120 100644 --- a/packages/chronicle/src/server/api/apis-proxy.ts +++ b/packages/chronicle/src/server/api/apis-proxy.ts @@ -1,5 +1,6 @@ import { defineHandler, HTTPError } from 'nitro'; import { loadConfig } from '@/lib/config'; +import { substituteEnvVars } from '@/lib/env'; import { loadApiSpecs } from '@/lib/openapi'; interface ProxyRequest { @@ -37,7 +38,7 @@ export default defineHandler(async event => { throw new HTTPError({ status: 400, message: 'Invalid path' }); } - const url = spec.server.url + path; + const url = substituteEnvVars(spec.server.url) + path; try { const response = await fetch(url, { diff --git a/packages/chronicle/src/server/utils/api-markdown.ts b/packages/chronicle/src/server/utils/api-markdown.ts index 87bc32e..2aed663 100644 --- a/packages/chronicle/src/server/utils/api-markdown.ts +++ b/packages/chronicle/src/server/utils/api-markdown.ts @@ -3,6 +3,7 @@ import { HTTPError } from 'nitro' import { loadConfig } from '@/lib/config' import { loadApiSpecs } from '@/lib/openapi' import { findApiOperation } from '@/lib/api-routes' +import { substituteEnvVars } from '@/lib/env' import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema' import { generateCurl } from '@/lib/snippet-generators' @@ -21,7 +22,7 @@ export async function handleApiMarkdown(pathname: string) { throw new HTTPError({ status: 404, message: 'Not Found' }) } - const md = generateApiMarkdown(match.method, match.path, match.operation, match.spec.server.url, match.spec.auth) + const md = generateApiMarkdown(match.method, match.path, match.operation, substituteEnvVars(match.spec.server.url), match.spec.auth) return new Response(md, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } }) } From c05354ad37d50bcd0ce6d05c70c7a27e0cecc1f1 Mon Sep 17 00:00:00 2001 From: Rishabh Date: Mon, 18 May 2026 11:16:11 +0530 Subject: [PATCH 2/2] feat: resolve env vars in loadApiSpecs, show server URL in UI Resolve ${VAR} in server.url via loadApiSpecs so resolved URL flows to UI components, API proxy, md route, and /api/specs endpoint from a single place. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/chronicle/src/components/api/api-overview.tsx | 4 ++-- packages/chronicle/src/lib/openapi.ts | 3 ++- packages/chronicle/src/server/api/apis-proxy.ts | 3 +-- packages/chronicle/src/server/utils/api-markdown.ts | 3 +-- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/chronicle/src/components/api/api-overview.tsx b/packages/chronicle/src/components/api/api-overview.tsx index 76c4b01..8fc4aaf 100644 --- a/packages/chronicle/src/components/api/api-overview.tsx +++ b/packages/chronicle/src/components/api/api-overview.tsx @@ -21,7 +21,7 @@ interface ApiOverviewProps { auth?: { type: string; header: string; placeholder?: string } } -export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps) { +export function ApiOverview({ method, path, operation, serverUrl, auth }: ApiOverviewProps) { const params = (operation.parameters ?? []) as OpenAPIV3.ParameterObject[] const body = getRequestBody(operation.requestBody as OpenAPIV3.RequestBodyObject | undefined) @@ -36,7 +36,7 @@ export function ApiOverview({ method, path, operation, auth }: ApiOverviewProps) ? headerFields : [] - const fullUrl = '{domain}' + path + const fullUrl = serverUrl + path const snippetHeaders: Record = {} if (auth) snippetHeaders[auth.header] = auth.placeholder ?? 'YOUR_API_KEY' if (body) snippetHeaders['Content-Type'] = body.contentType ?? 'application/json' diff --git a/packages/chronicle/src/lib/openapi.ts b/packages/chronicle/src/lib/openapi.ts index ef9183d..3116bf3 100644 --- a/packages/chronicle/src/lib/openapi.ts +++ b/packages/chronicle/src/lib/openapi.ts @@ -3,6 +3,7 @@ import path from 'node:path' import { parse as parseYaml } from 'yaml' import type { OpenAPIV2, OpenAPIV3 } from 'openapi-types' import type { ApiConfig, ApiServerConfig, ApiAuthConfig } from '@/types/config' +import { substituteEnvVars } from '@/lib/env' type JsonObject = Record @@ -41,7 +42,7 @@ export async function loadApiSpec(config: ApiConfig, projectRoot: string): Promi return { name: config.name, basePath: config.basePath, - server: config.server, + server: { ...config.server, url: substituteEnvVars(config.server.url) }, auth: config.auth, document: v3Doc, } diff --git a/packages/chronicle/src/server/api/apis-proxy.ts b/packages/chronicle/src/server/api/apis-proxy.ts index 1d2b120..eb4a3ba 100644 --- a/packages/chronicle/src/server/api/apis-proxy.ts +++ b/packages/chronicle/src/server/api/apis-proxy.ts @@ -1,6 +1,5 @@ import { defineHandler, HTTPError } from 'nitro'; import { loadConfig } from '@/lib/config'; -import { substituteEnvVars } from '@/lib/env'; import { loadApiSpecs } from '@/lib/openapi'; interface ProxyRequest { @@ -38,7 +37,7 @@ export default defineHandler(async event => { throw new HTTPError({ status: 400, message: 'Invalid path' }); } - const url = substituteEnvVars(spec.server.url) + path; + const url = spec.server.url + path; try { const response = await fetch(url, { diff --git a/packages/chronicle/src/server/utils/api-markdown.ts b/packages/chronicle/src/server/utils/api-markdown.ts index 2aed663..87bc32e 100644 --- a/packages/chronicle/src/server/utils/api-markdown.ts +++ b/packages/chronicle/src/server/utils/api-markdown.ts @@ -3,7 +3,6 @@ import { HTTPError } from 'nitro' import { loadConfig } from '@/lib/config' import { loadApiSpecs } from '@/lib/openapi' import { findApiOperation } from '@/lib/api-routes' -import { substituteEnvVars } from '@/lib/env' import { flattenSchema, generateExampleJson, type SchemaField } from '@/lib/schema' import { generateCurl } from '@/lib/snippet-generators' @@ -22,7 +21,7 @@ export async function handleApiMarkdown(pathname: string) { throw new HTTPError({ status: 404, message: 'Not Found' }) } - const md = generateApiMarkdown(match.method, match.path, match.operation, substituteEnvVars(match.spec.server.url), match.spec.auth) + const md = generateApiMarkdown(match.method, match.path, match.operation, match.spec.server.url, match.spec.auth) return new Response(md, { headers: { 'Content-Type': 'text/markdown; charset=utf-8' } }) }