Skip to content

Commit 2024c89

Browse files
committed
feat: runtime baseUrl with optional imports
- BaseUrlRuntime (expression + optional imports), getFullRoute, merge imports in client generator - Docs (output reference, fetch/set-base-url) and route tests
1 parent f28371d commit 2024c89

7 files changed

Lines changed: 231 additions & 10 deletions

File tree

docs/content/docs/guides/fetch.mdx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,18 @@ export default defineConfig({
2929
});
3030
```
3131

32+
## Runtime base URL
33+
34+
For a host that is only known at runtime (for example `process.env.API_BASE_URL` in Node or `import.meta.env.VITE_*` in Vite), use `baseUrl.runtime` instead of a fixed string. The built-in fetch client embeds the expression in generated URL template literals, so you can keep using `override.fetch` options such as `runtimeValidation` and `includeHttpResponseReturnType` without a custom mutator.
35+
36+
```ts title="orval.config.ts"
37+
baseUrl: {
38+
runtime: 'process.env.API_BASE_URL',
39+
},
40+
```
41+
42+
See the [output `baseUrl` reference](/docs/reference/configuration/output#runtime) for `imports` and expressions such as `env.API_BASE_URL` from a shared module (for example `import { env } from '../../env'`). MSW mock host filtering uses the separate [`mock.baseUrl`](/docs/reference/configuration/output#mock-options) option.
43+
3244
## Generated Output
3345

3446
Orval generates three things for each endpoint:

docs/content/docs/guides/set-base-url.mdx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ export default defineConfig({
4444
});
4545
```
4646

47+
## Runtime base URL
48+
49+
To resolve the API base URL when your app runs (for example from environment variables) while using Orval's generated fetch client and built-in `override.fetch` features, set [`baseUrl.runtime`](/docs/reference/configuration/output#runtime) in your output config. This is separate from [`mock.baseUrl`](/docs/reference/configuration/output#mock-options), which only affects generated MSW handlers.
50+
4751
## HTTP Client Configuration
4852

4953
### Axios

docs/content/docs/reference/configuration/output.mdx

Lines changed: 72 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -235,9 +235,71 @@ export default defineConfig({
235235
});
236236
```
237237

238-
### getBaseUrlFromSpecification
238+
### runtime {#runtime}
239239

240-
Read URL from the OpenAPI `servers` field:
240+
Embed a JavaScript expression into generated request URLs so the same build can call different hosts at runtime (for example with Docker images and environment variables). The value is emitted inside template literals in generated clients; only use trusted expressions from your configuration.
241+
242+
```ts title="orval.config.ts"
243+
export default defineConfig({
244+
petstore: {
245+
output: {
246+
baseUrl: {
247+
runtime: 'process.env.API_BASE_URL',
248+
},
249+
},
250+
},
251+
});
252+
```
253+
254+
#### runtime
255+
256+
**Type:** `String`
257+
258+
JavaScript expression used inside generated template literals for the request base URL. Set this to the expression only (for example `process.env.API_BASE_URL`), not including `` `${...}` ``; Orval wraps it for you.
259+
260+
#### imports
261+
262+
**Type:** `GeneratorImport[]`
263+
264+
Optional. When `runtime` references a symbol from another module, list the imports Orval should emit into generated clients. Paths are relative to the generated file, same idea as mutator imports. The `runtime` expression must be valid where the generated code runs (after those imports).
265+
266+
Use a default import:
267+
268+
```ts title="orval.config.ts"
269+
export default defineConfig({
270+
petstore: {
271+
output: {
272+
baseUrl: {
273+
runtime: 'apiBase',
274+
imports: [{ name: 'apiBase', importPath: '../config/api' }],
275+
},
276+
},
277+
},
278+
});
279+
```
280+
281+
Or a named export used as an object (for example `import { env } from '../../env'` and `env.API_BASE_URL` in application code) — set `runtime` to that property access and import the object under `name`:
282+
283+
```ts title="orval.config.ts"
284+
export default defineConfig({
285+
petstore: {
286+
output: {
287+
baseUrl: {
288+
runtime: 'env.API_BASE_URL',
289+
imports: [{ name: 'env', importPath: '../../env' }],
290+
},
291+
},
292+
},
293+
});
294+
```
295+
296+
Adjust `importPath` so it resolves from the generated client file to your module (the example assumes the client is nested deeper than `env.ts`).
297+
298+
### getBaseUrlFromSpecification {#getbaseurlfromspecification}
299+
300+
**Type:** `Boolean`
301+
302+
Read the base URL from the OpenAPI `servers` field instead of a fixed string. When `true`, Orval resolves it from the spec’s `servers` entry (optionally with `variables` and `index` below).
241303

242304
```ts title="orval.config.ts"
243305
export default defineConfig({
@@ -254,11 +316,17 @@ export default defineConfig({
254316
});
255317
```
256318

257-
### index
319+
#### variables
320+
321+
**Type:** `Record<string, string>`
322+
323+
Values for variables used in server URL templates from the OpenAPI `servers` field.
324+
325+
#### index
258326

259327
**Type:** `Number`
260328

261-
Select which server URL index to use (when `getBaseUrlFromSpecification: true`):
329+
Which `servers` entry to use (0-based) when multiple URLs are defined:
262330

263331
```ts title="orval.config.ts"
264332
export default defineConfig({

packages/core/src/getters/route.test.ts

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ import { describe, expect, it, test } from 'vitest';
33
import type {
44
BaseUrlFromConstant,
55
BaseUrlFromSpec,
6+
BaseUrlRuntime,
67
OpenApiServerObject,
78
} from '../types';
8-
import { getFullRoute, getRoute, getRouteAsArray } from './route';
9+
import {
10+
getBaseUrlRuntimeImports,
11+
getFullRoute,
12+
getRoute,
13+
getRouteAsArray,
14+
} from './route';
915

1016
describe('getRoute getter', () => {
1117
for (const [input, expected] of [
@@ -139,6 +145,31 @@ describe('getFullRoute getter', () => {
139145
expect(getFullRoute(path, servers, config)).toBe(expected);
140146
});
141147
}
148+
for (const [path, servers, config, expected] of [
149+
[
150+
'/pets',
151+
undefined,
152+
{ runtime: 'process.env.API_BASE_URL' },
153+
'${process.env.API_BASE_URL}/pets',
154+
],
155+
[
156+
'/pets',
157+
undefined,
158+
{ runtime: 'import.meta.env.VITE_API_URL' },
159+
'${import.meta.env.VITE_API_URL}/pets',
160+
],
161+
[
162+
'/pets',
163+
undefined,
164+
{ runtime: 'env.API_BASE_URL' },
165+
'${env.API_BASE_URL}/pets',
166+
],
167+
['/path', undefined, { runtime: '' }, '/path'],
168+
] as [string, OpenApiServerObject[] | undefined, BaseUrlRuntime, string][]) {
169+
it(`should make path ${path} with runtime baseUrl ${JSON.stringify(config)} be ${expected}`, () => {
170+
expect(getFullRoute(path, servers, config)).toBe(expected);
171+
});
172+
}
142173
for (const [path, servers, config, error] of [
143174
[
144175
'/path',
@@ -169,6 +200,55 @@ describe('getFullRoute getter', () => {
169200
}
170201
});
171202

203+
describe('getBaseUrlRuntimeImports', () => {
204+
it('returns [] when baseUrl is omitted', () => {
205+
expect(getBaseUrlRuntimeImports()).toEqual([]);
206+
});
207+
208+
it('returns [] for string baseUrl', () => {
209+
expect(getBaseUrlRuntimeImports('https://api.example.com')).toEqual([]);
210+
});
211+
212+
it('returns [] for BaseUrlFromSpec', () => {
213+
expect(
214+
getBaseUrlRuntimeImports({ getBaseUrlFromSpecification: true }),
215+
).toEqual([]);
216+
});
217+
218+
it('returns [] for BaseUrlFromConstant', () => {
219+
expect(
220+
getBaseUrlRuntimeImports({
221+
getBaseUrlFromSpecification: false,
222+
baseUrl: 'https://x.com',
223+
}),
224+
).toEqual([]);
225+
});
226+
227+
it('returns [] for BaseUrlRuntime without imports', () => {
228+
expect(getBaseUrlRuntimeImports({ runtime: 'process.env.X' })).toEqual([]);
229+
});
230+
231+
it('returns imports for BaseUrlRuntime with imports', () => {
232+
const imports = [{ name: 'apiBase', importPath: '../config/api' }];
233+
expect(
234+
getBaseUrlRuntimeImports({
235+
runtime: 'apiBase',
236+
imports,
237+
}),
238+
).toEqual(imports);
239+
});
240+
241+
it('returns imports for env object pattern (runtime uses property access)', () => {
242+
const imports = [{ name: 'env', importPath: '../../env' }];
243+
expect(
244+
getBaseUrlRuntimeImports({
245+
runtime: 'env.API_BASE_URL',
246+
imports,
247+
}),
248+
).toEqual(imports);
249+
});
250+
});
251+
172252
describe('getRouteAsArray getter', () => {
173253
test.each([
174254
['/v${version}/the/nope/${param}', "'v',version,'the','nope',param"],

packages/core/src/getters/route.ts

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,32 @@ import { TEMPLATE_TAG_REGEX } from '../constants';
22
import type {
33
BaseUrlFromConstant,
44
BaseUrlFromSpec,
5+
BaseUrlRuntime,
6+
GeneratorImport,
7+
NormalizedOutputOptions,
58
OpenApiServerObject,
69
} from '../types';
7-
import { camel, isString, sanitize } from '../utils';
10+
import { camel, isObject, isString, sanitize } from '../utils';
11+
12+
function isBaseUrlRuntime(
13+
baseUrl: string | BaseUrlFromConstant | BaseUrlFromSpec | BaseUrlRuntime,
14+
): baseUrl is BaseUrlRuntime {
15+
return (
16+
isObject(baseUrl) &&
17+
'runtime' in baseUrl &&
18+
typeof baseUrl.runtime === 'string'
19+
);
20+
}
21+
22+
/**
23+
* Wraps a runtime expression for generated URL template literals.
24+
* Pass the expression only (e.g. `process.env.API_BASE_URL`), not a `${...}` fragment.
25+
*/
26+
function runtimeExpressionToUrlPrefix(expression: string): string {
27+
const t = expression.trim();
28+
if (!t) return '';
29+
return '${' + t + '}';
30+
}
831

932
const TEMPLATE_TAG_IN_PATH_REGEX = /\/([\w]+)(?:\$\{)/g; // all dynamic parts of path
1033

@@ -47,11 +70,19 @@ export function getRoute(route: string) {
4770
export function getFullRoute(
4871
route: string,
4972
servers: OpenApiServerObject[] | undefined,
50-
baseUrl: string | BaseUrlFromConstant | BaseUrlFromSpec | undefined,
73+
baseUrl:
74+
| string
75+
| BaseUrlFromConstant
76+
| BaseUrlFromSpec
77+
| BaseUrlRuntime
78+
| undefined,
5179
): string {
5280
const getBaseUrl = (): string => {
5381
if (!baseUrl) return '';
5482
if (isString(baseUrl)) return baseUrl;
83+
if (isBaseUrlRuntime(baseUrl)) {
84+
return runtimeExpressionToUrlPrefix(baseUrl.runtime);
85+
}
5586
if (baseUrl.getBaseUrlFromSpecification) {
5687
if (!servers) {
5788
throw new Error(
@@ -99,6 +130,17 @@ export function getFullRoute(
99130
return fullRoute;
100131
}
101132

133+
/**
134+
* Returns `GeneratorImport` entries for {@link BaseUrlRuntime.imports} when `baseUrl` is a runtime config.
135+
*/
136+
export function getBaseUrlRuntimeImports(
137+
baseUrl?: NormalizedOutputOptions['baseUrl'],
138+
): GeneratorImport[] {
139+
if (!baseUrl) return [];
140+
if (!isBaseUrlRuntime(baseUrl)) return [];
141+
return baseUrl.imports ?? [];
142+
}
143+
102144
// Creates a mixed use array with path variables and string from template string route
103145
export function getRouteAsArray(route: string): string {
104146
return route

packages/core/src/types.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export interface NormalizedOutputOptions {
5252
packageJson?: PackageJson;
5353
headers: boolean;
5454
indexFiles: boolean;
55-
baseUrl?: string | BaseUrlFromSpec | BaseUrlFromConstant;
55+
baseUrl?: string | BaseUrlFromSpec | BaseUrlFromConstant | BaseUrlRuntime;
5656
allParamsOptional: boolean;
5757
urlEncodeParameters: boolean;
5858
unionAddMissingProperties: boolean;
@@ -215,6 +215,18 @@ export interface BaseUrlFromConstant {
215215
baseUrl: string;
216216
}
217217

218+
/**
219+
* Embed a runtime JavaScript expression into generated URL template literals
220+
* (e.g. `process.env.API_BASE_URL`) so the same build can target different hosts at runtime.
221+
*/
222+
export interface BaseUrlRuntime {
223+
runtime: string;
224+
/** Named imports for symbols used in `runtime` (e.g. `{ name: 'apiBase', importPath: '../config' }`). */
225+
imports?: GeneratorImport[];
226+
getBaseUrlFromSpecification?: never;
227+
baseUrl?: never;
228+
}
229+
218230
export const PropertySortOrder = {
219231
ALPHABETICAL: 'Alphabetical',
220232
SPECIFICATION: 'Specification',
@@ -279,7 +291,7 @@ export interface OutputOptions {
279291
packageJson?: string;
280292
headers?: boolean;
281293
indexFiles?: boolean;
282-
baseUrl?: string | BaseUrlFromSpec | BaseUrlFromConstant;
294+
baseUrl?: string | BaseUrlFromSpec | BaseUrlFromConstant | BaseUrlRuntime;
283295
allParamsOptional?: boolean;
284296
urlEncodeParameters?: boolean;
285297
unionAddMissingProperties?: boolean;

packages/orval/src/client.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
import {
2323
asyncReduce,
2424
generateDependencyImports,
25+
getBaseUrlRuntimeImports,
2526
isFunction,
2627
OutputClient,
2728
pascal,
@@ -254,6 +255,8 @@ export const generateOperations = (
254255
options: GeneratorOptions,
255256
output: NormalizedOutputOptions,
256257
): Promise<GeneratorOperations> => {
258+
const baseUrlImports = getBaseUrlRuntimeImports(output.baseUrl);
259+
257260
return asyncReduce(
258261
verbsOptions,
259262
async (acc, verbOption) => {
@@ -293,7 +296,7 @@ export const generateOperations = (
293296
implementation: hasImplementation
294297
? verbOption.doc + client.implementation
295298
: client.implementation,
296-
imports: client.imports,
299+
imports: [...baseUrlImports, ...client.imports],
297300
implementationMock: generatedMock.implementation,
298301
importsMock: generatedMock.imports,
299302
tags: verbOption.tags,

0 commit comments

Comments
 (0)