Item 259: an optional query string held in a const is the URL's query, not a path segment

This commit is contained in:
Ingo Schnabel
2026-09-26 22:46:23 +02:00
parent 3e923bed42
commit c05ac33e5a
8 changed files with 71 additions and 7 deletions

View File

@@ -4,4 +4,4 @@
server.url=http://localhost:8787
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
version=399
version=400

View File

@@ -3,7 +3,7 @@ quarkus.http.port=8787
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
# info version (referenced below via property expression, not duplicated).
agenticcode.version=399
agenticcode.version=400
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
mp.openapi.extensions.smallrye.info.title=AgenticCode API

View File

@@ -140,6 +140,19 @@ class AngularApiIT {
responses:
200:
description: ok
/api/login/spnego:
get:
operationId: spnego
tags:
- Authentication
parameters:
- name: forcedTenant
in: query
schema:
type: string
responses:
200:
description: ok
/api/v1/orders:
get:
operationId: listOrders
@@ -166,6 +179,10 @@ class AngularApiIT {
public boolean login() {
return true;
}
public boolean spnego(String forcedTenant) {
return true;
}
}
""");
// item 252: tag `i18n`, delegate `I18nApiDelegate`
@@ -287,6 +304,8 @@ class AngularApiIT {
// `POST /ShopApi/api/login`, served by the delegate method (item 253)
.body("find { it.name == 'OrderApiService.login' }.counterpartModule", equalTo("shop.api.AuthenticationController"))
.body("find { it.name == 'OrderApiService.login' }.counterpartName", equalTo("login"))
// `'/ShopApi/api/login/spnego' + param`, param an optional `?forcedTenant=…` (item 259)
.body("find { it.name == 'OrderApiService.spnego' }.counterpartName", equalTo("spnego"))
// generated delegate: <Tag>ApiDelegate + operationId
.body("find { it.name == 'OrdersService.listOrders' }.counterpartName", equalTo("listOrders"))
.body("find { it.name == 'OrdersService.listOrders' }.counterpartModule", equalTo("shop.api.OrdersController"))

View File

@@ -801,16 +801,18 @@ function typeText(expr) {
}
// The URL as far as it folds: literal parts as written, the rest `{name}` (the last identifier of the expression).
function urlText(sf, expr) {
function urlText(sf, expr, depth = 0) {
expr = unwrap(expr);
const folded = literalString(expr);
if (folded !== null) return folded;
if (ts.isTemplateExpression(expr)) {
return expr.head.text + expr.templateSpans.map((sp) => urlText(sf, sp.expression) + sp.literal.text).join('');
return expr.head.text + expr.templateSpans.map((sp) => urlText(sf, sp.expression, depth) + sp.literal.text).join('');
}
if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.PlusToken) {
return urlText(sf, expr.left) + urlText(sf, expr.right);
return urlText(sf, expr.left, depth) + urlText(sf, expr.right, depth);
}
const query = optionalQuery(sf, expr, depth);
if (query !== null) return query;
let e = expr;
while (ts.isCallExpression(e) && e.arguments.length) e = unwrap(e.arguments[0]); // encodeURIComponent(x)
if (ts.isPropertyAccessExpression(e)) return `{${e.name.text}}`;
@@ -818,6 +820,28 @@ function urlText(sf, expr) {
return '{' + e.getText(sf).replace(/\s+/g, '') + '}';
}
// Item 259: an optional query string — `const param = t ? '?forcedTenant=' + t : ''; get(api + param)` —
// is the non-empty branch (`?forcedTenant={t}`), not a `{param}` path segment. Only a conditional with
// one branch '' and the other starting with '?' or '&', inline or as the initializer of a const.
function optionalQuery(sf, expr, depth) {
if (depth > 5) return null;
if (ts.isIdentifier(expr) || ts.isPropertyAccessExpression(expr)) {
const d = declarationOf(ts.isIdentifier(expr) ? expr : expr.name);
const isConst = d && ts.isVariableDeclaration(d) && d.initializer && ts.isVariableDeclarationList(d.parent)
&& (d.parent.flags & ts.NodeFlags.Const) !== 0;
if (!isConst) return null;
const init = unwrap(d.initializer);
return ts.isConditionalExpression(init) ? optionalQuery(sf, init, depth + 1) : null;
}
if (!ts.isConditionalExpression(expr)) return null;
for (const [empty, other] of [[expr.whenFalse, expr.whenTrue], [expr.whenTrue, expr.whenFalse]]) {
if (literalString(empty) !== '') continue;
const text = urlText(sf, other, depth + 1);
if (text.startsWith('?') || text.startsWith('&')) return text;
}
return null;
}
// hey-api (@hey-api/openapi-ts sdk.gen.ts): `export const getX = (options) => (options.client ?? client).get<...>({ url: '/x/{id}', ...options })`.
function heyApiEndpoint(sf, name, fn, out) {
let found = null;

View File

@@ -173,7 +173,9 @@ class AngularSidecarTest {
"OrderApiService.load|GET|/ShopData/api/v1/orders/{id}|httpclient|null|OrderSummary",
"OrderApiService.store|POST|/ShopData/api/v1/orders|httpclient|OrderSummary|OrderSummary",
"OrderApiService.twice|GET|/ShopData/api/v1/twice|httpclient|null|string",
"OrderApiService.login|POST|/ShopApi/api/login|httpclient|{}|boolean"), http);
"OrderApiService.login|POST|/ShopApi/api/login|httpclient|{}|boolean",
// item 259: an optional query string held in a const is the query, not a path segment
"OrderApiService.spnego|GET|/ShopApi/api/login/spnego?forcedTenant={tenant}|httpclient|null|boolean"), http);
assertEquals("spec/shop-api.yaml", facts.virtualFiles().get("app/src/generated/api/orders.service.ts"));
assertEquals("spec/shop-api.yaml", facts.virtualFiles().get("app/src/generated/model/orderDTO.ts"));

View File

@@ -27,4 +27,9 @@ export class OrderApiService {
login(): Observable<boolean> {
return this.http.post<boolean>('/ShopApi/api/login', {});
}
spnego(tenant?: string): Observable<boolean> {
const param = tenant ? '?forcedTenant=' + tenant : '';
return this.http.get<boolean>('/ShopApi/api/login/spnego' + param);
}
}

View File

@@ -1660,7 +1660,9 @@ immediate subdirectory with an `angular.json` or `tsconfig.json` is a workspace
- **Hand-written HttpClient services** (`generator: httpclient`). A class method that calls `this.http.<verb><T>(url, body)`
is the endpoint `<Service>.<method>`. The URL folds through constants and `environment.ts`
(`${environment.apiUrl}/praemienkonto/branche-anlegen` → `/K3Data/api/v1/praemienkonto/branche-anlegen`).
Scheme and host are dropped, and what does not fold stays `{name}`. `responseType` is the type argument,
Scheme and host are dropped, and what does not fold stays `{name}`. An optional query string held in a
`const` (`const param = t ? '?forcedTenant=' + t : ''; get(api + param)`) is the query, not a path
segment: `…/login/spnego?forcedTenant={t}`, `queryParams: [forcedTenant]` (item 259). `responseType` is the type argument,
and `requestType` is the body's type. A component's `this.verwaltungService.erstelleBranche(dto)` targets
the endpoint (`calleeMethod`). K4 has 164 of them.
- **A generated OpenAPI client that is not on disk** (`generator: openapi`). When a workspace's

View File

@@ -187,6 +187,18 @@ analysis fell back to reading source. Gap ids L1–L6 refer to that file.
Measured 2026-09-26 (server 399): `k3/soap-endpoints?port=WSK369` → `typesPackages`
`rpc.entirex.softwareag.com.wsk369`, 4 clients (service, integration test, two test doubles) via wsimport + jaxb.
- [x] **259. An optional query string held in a variable becomes a path segment (L10 remainder)** (found 2026-09-26, done 2026-09-26)
`K3AuthModuleService.spnegoLogin` (k4-suche) calls `this.http.get(spnegoApi + param)` with
`const param = forcedMandant ? '?forcedTenant=' + forcedMandant : ''`. The sidecar folded the URL to
`/K3Koala/api/login/spnego{param}`, so the call did not pair with `GET /K3Koala/api/login/spnego`
(`AuthenticationController.spnego`). Now a conditional with one branch `''` and the other starting with
`?` or `&`, inline or as the initializer of a `const`, folds to the non-empty branch; the query is split
off as before (`queryParams`). `let` variables and every other shape stay `{name}`.
Measured 2026-09-26 (server 400, koala deep 35 s): `spnegoLogin` → `/K3Koala/api/login/spnego`, pairs with
`at.k3.koala.security.AuthenticationController.spnego`; `koala/counterparts?kind=rest&unmatched=true` 9 → 8
(3 unimplemented operations, 5 non-backend calls).
## High priority — comments, dead code and the counterpart relation (2026-08-27)
Three gaps reported after the `VermittlerController` reengineering round (nine UPMS services, four of