Compare commits

...

2 commits
v0.4.1 ... main

Author SHA1 Message Date
Claude
b00a4320a7 core-fetch: baseUrl accepts () => string for lazy resolution
Lets consumers pass `() => import.meta.env.BASE_URL` without orval's CJS
bundle path blowing up on the top-level `import.meta` reference. The
getter is invoked at request time rather than factory time, so the
mutator file can be loaded by orval (which bundles to CJS) without
evaluating `import.meta`.

Closes uwe.admin/webapp-template#21 (scaffold side).

Bump to v0.5.0 — additive change (string form still works) but new
shape for `CoreFetchOptions.baseUrl` is a public API change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:49:06 +02:00
8677faf54b postprocess-openapi: also fix type: Any and duplicate params
Two more oatpp 1.3 spec quirks surfaced when fewo-webapp's #469
increment 2 wired up Orval's strict Zod codegen on top of the v0.4.1
`required: []` strip:

- `"type": "Any"` is emitted for `oatpp::Any` / `oatpp::Fields` fields.
  OpenAPI 3.0 has no "Any" type — the empty schema `{}` (or absence of
  `type`) is the canonical "any value" form. Strip the offending key
  recursively across the whole spec.
- Some endpoints emit the same path parameter twice (same `name` + same
  `in: path`). Dedupe by `(name, in)` preserving first occurrence.

These were caught only by `@scalar/openapi-parser`'s strict mode (used by
Orval's Zod project); the fetch-client project tolerated them. Without
both fixes, fewoZod fails with `must NOT have duplicate items` and
`must match exactly one schema in oneOf` per affected component.

Bumps to v0.4.2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-05 21:25:49 +02:00
5 changed files with 85 additions and 11 deletions

View file

@ -48,12 +48,55 @@ for path, methods in paths.items():
# breaks strict consumers like Orval's Zod generator. Strip empty arrays —
# absence of the keyword has the same semantics.
schemas = spec.get("components", {}).get("schemas", {})
stripped = 0
stripped_required = 0
for sch in schemas.values():
if isinstance(sch, dict) and sch.get("required") == []:
del sch["required"]
stripped += 1
stripped_required += 1
# oatpp 1.3 emits `"type": "Any"` for `oatpp::Any` / `oatpp::Fields` fields.
# `Any` is not a valid OpenAPI 3.0 type — the spec uses an empty schema `{}`
# (or `nullable: true` with no type) to mean "any value". Replace `Any` with
# an empty schema; recurse so we catch nested cases (object property,
# array item, etc.).
fixed_any = 0
def fix_any(node):
global fixed_any
if isinstance(node, dict):
if node.get("type") == "Any":
del node["type"]
fixed_any += 1
for v in node.values():
fix_any(v)
elif isinstance(node, list):
for item in node:
fix_any(item)
fix_any(spec)
# oatpp 1.3 sometimes emits the same path parameter twice (same name + same
# `in: path`). OpenAPI 3.0 rejects duplicate parameters. Dedupe by (name, in)
# preserving first occurrence.
deduped_params = 0
for path_item in paths.values():
if not isinstance(path_item, dict):
continue
for op in path_item.values():
if not isinstance(op, dict) or "parameters" not in op:
continue
seen = set()
unique = []
for p in op["parameters"]:
key = (p.get("name"), p.get("in"))
if key in seen:
deduped_params += 1
continue
seen.add(key)
unique.append(p)
op["parameters"] = unique
SRC.write_text(json.dumps(spec, indent=2))
print(f" postprocessed {SRC}{len(paths)} paths"
+ (f", stripped empty `required` from {stripped} schemas" if stripped else ""))
report = [f"{len(paths)} paths"]
if stripped_required: report.append(f"stripped empty `required` from {stripped_required} schemas")
if fixed_any: report.append(f"replaced {fixed_any} invalid `type: Any` with any-schema")
if deduped_params: report.append(f"deduped {deduped_params} duplicate parameter(s)")
print(f" postprocessed {SRC}" + ", ".join(report))

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "@uschuster/webapp-scaffold",
"version": "0.3.6",
"version": "0.5.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@uschuster/webapp-scaffold",
"version": "0.3.6",
"version": "0.5.0",
"license": "UNLICENSED",
"bin": {
"webapp-scaffold-fetch-openapi": "bin/fetch-openapi.sh",

View file

@ -1,6 +1,6 @@
{
"name": "@uschuster/webapp-scaffold",
"version": "0.4.1",
"version": "0.5.0",
"description": "Shared build scripts + Vite config factories for webapp-template-derived projects.",
"type": "module",
"bin": {

View file

@ -173,4 +173,23 @@ describe('createCoreFetch — request shape', () => {
await cf('/v1/x');
expect(captured).toBe('https://api.example.com/v1/x');
});
it('baseUrl getter is resolved at request time, not factory time', async () => {
const captured: string[] = [];
let dynamicBase = 'https://first.example.com';
const cf = createCoreFetch({
baseUrl: () => dynamicBase,
fetchImpl: ((url: string) => {
captured.push(url);
return Promise.resolve(jsonResponse({}));
}) as unknown as typeof fetch,
});
await cf('/v1/x');
dynamicBase = 'https://second.example.com/';
await cf('/v1/y');
expect(captured).toEqual([
'https://first.example.com/v1/x',
'https://second.example.com/v1/y',
]);
});
});

View file

@ -28,8 +28,16 @@ export interface CoreFetchRequest {
}
export interface CoreFetchOptions {
/** Prefix for every request; strip trailing slash. */
baseUrl?: string;
/**
* Prefix for every request; trailing slash stripped at resolve time.
*
* Accepts a string OR a getter so consumers that derive the base from
* `import.meta.env.BASE_URL` (or any other late-bound source) can pass
* the getter form. The getter is invoked at request time, never at
* factory-construction time which keeps orval's CJS bundling of the
* mutator file from blowing up on top-level `import.meta` references.
*/
baseUrl?: string | (() => string);
/** `'body'` returns the parsed body (default); `'wrapped'` returns { data, status, headers }. */
responseShape?: ResponseShape;
@ -97,7 +105,11 @@ export type CoreFetch = CoreFetchWrapped & CoreFetchBody;
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
const base = (opts.baseUrl ?? '').replace(/\/$/, '');
// Lazy base resolution — see CoreFetchOptions.baseUrl JSDoc.
const resolveBase = (): string => {
const raw = typeof opts.baseUrl === 'function' ? opts.baseUrl() : (opts.baseUrl ?? '');
return raw.replace(/\/$/, '');
};
// Resolve fetch at call time, not construction time, so tests that
// `vi.stubGlobal('fetch', ...)` after module import see the stub.
const callFetch: typeof fetch = opts.fetchImpl
@ -135,7 +147,7 @@ export function createCoreFetch(opts: CoreFetchOptions = {}): CoreFetch {
let r: Response;
try {
r = await callFetch(`${base}${url}`, {
r = await callFetch(`${resolveBase()}${url}`, {
credentials: 'include',
...init,
headers,