Skip to content

Commit e8f22b7

Browse files
feat(project-engine-client): add GET /v1/url/resolve to the vendored client + mock (#1771)
<!-- mysticat-pr-skill --> ## 1. Abstract Adds the Semrush Project Engine `GET /v1/url/resolve` endpoint to the vendored `spacecat-shared-project-engine-client` — its generated types, the spec overlay, and the stateful Counterfact mock. This is §1 of the cross-repo brand-URL normalization spec. ## 2. Reasoning When we add a brand URL to a Semrush AIO project we currently send the raw URL verbatim (e.g. `https://www.lovesac.com`). Semrush stores brand URLs in a canonical, scheme-less form (`lovesac.com`) and its own frontend normalizes first by calling the Project Engine URL-resolve endpoint before writing. Sending the raw URL produces a duplicate/mismatched brand-URL entry that does not line up with the canonical benchmark Semrush already holds. Fixing that requires the endpoint to exist in the vendored typed client first — this PR is that prerequisite. Reported by the Semrush team in the Elements-wrappers review thread (2026-07-02/03); the live contract was captured directly against prod. ## 3. High-level overview of the changes Models a live endpoint that is absent from the vendored swagger, following the established overlay + regenerate + mock pattern (precedent CR1/CR8). No consumer-facing client code changes — the client is a thin generic `openapi-fetch` wrapper, so once the path is in the generated types a caller just does `client.GET('/v1/url/resolve', { params: { query: { primary_url } } })`. - **Spec overlay (CR16):** adds a `model.UrlResolveResponse` schema (`{ domain, primary_url, is_valid }`, all required) and the `GET /v1/url/resolve` path (query param `primary_url` required, `200`/`400`/`401` responses). Regenerating from the overlay updates `src/generated/types.ts` and the pydantic models; the regen also catches up a previously-stale pydantic artifact (a CR14 field that had only been regenerated into the TS types). - **Mock behaviour (before → after):** the endpoint did not exist → the mock now canonicalizes `primary_url`. It strips the scheme and a leading `www.` while preserving any other subdomain and the path (`http://www.lovesac.com/products` → `lovesac.com/products`; `blog.hubspot.com` stays `blog.hubspot.com`); `domain` is the registrable apex (subdomain stripped too). Unresolvable/garbage input returns `{ domain: '', primary_url: '', is_valid: false }` at HTTP `200` — never an error — matching the live trap that consumers must check `is_valid`. - The canonicalization is a pure, unit-tested helper (`mock/url-resolve.js`) exposed on the mock context like the existing `tagId`/`parentIdField` helpers, rather than logic buried in the coverage-excluded route handler. - Endpoint inventory in `docs/mock-usage.md` updated to list the new route. Two intentional, documented mock simplifications: the apex is the last two dot-labels (so a multi-part public suffix like `example.co.uk` collapses to `co.uk`, unlike live's PSL), and an empty `primary_url=` returns `200 is_valid:false` rather than `400` (Counterfact enforces the `required` presence — a missing param still `400`s — but not the string `minLength`). ## 4. Required information - Spec: adobe/serenity-docs#25 - Other: brand-URL normalization tracked alongside the Serenity mapping-table work adobe/serenity-docs#23 ## 5. Affected / used mysticat-workspace projects - `spacecat-api-service` — will consume this endpoint (§2 of the spec): a new `resolveUrl` transport method + resolve-before-write on the brand-URL CREATE/EDIT paths. Blocked on this package being released (contract dependency). - `mysticat-data-service` — the Serenity migration CLI (§3, on the `feat/customer_onboarding` branch / #737) will add the same `resolve_url` call. Also blocked on this release. ## 6. Additional information outside the code - Booted the stateful Counterfact mock (`npm run test:e2e`) and drove the new `/v1/url/resolve` route end-to-end through the real `createSerenityProjectEngineApiClient` — verified the canonical case, the subdomain/path-preserving case, the `is_valid:false` garbage case (HTTP 200), and the missing-vs-empty `primary_url` request-validation behaviour. - The live contract behind the mock was captured directly against prod Project Engine (`https://adobe-hackathon.semrush.com`) with a real IMS bearer (serenity-docs#25 §0) — method, param, response shape, error codes, and the `is_valid:false` empty-string trap are ground truth, not assumptions. ## 7. Test plan - (a) Local: unit suite plus the offline overlay freshness gate (asserts CR16 both applies and is necessary against the vendored swagger), and the `MOCK_E2E=1` end-to-end suite driving the booted mock through the typed client — all exercised locally. - (b) Per-env: this package ships no runtime service, so there is no eph/dev/stage/prod deploy to verify. After merge, semantic-release publishes a new npm version and the GHCR mock Docker image republishes on the release tag; consumers verify by bumping `@adobe/spacecat-shared-project-engine-client` and calling `client.GET('/v1/url/resolve', { params: { query: { primary_url } } })` against the published mock image in their integration tests. ## 8. Deployment & merge order - This PR is the hard prerequisite for the rest of serenity-docs#25 — it must merge and release (npm + GHCR mock image) first. - After release: the api-service change (§2) bumps the dependency and adds the resolve-before-write; the migration CLI change (§3) does the same. Both are `related` and neither can build/test until this version publishes. Ordered sequence: merge + release this PR → api-service §2 + migration §3 bump the dep and proceed in parallel. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 75e9204 commit e8f22b7

16 files changed

Lines changed: 471 additions & 4 deletions

File tree

packages/spacecat-shared-project-engine-client/docs/mock-usage.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ method that calls it.
135135
| Method + path | Consumer | Behaviour |
136136
| --- | --- | --- |
137137
| `GET /v1/languages` | `listLanguages` | language catalog → `{ page, total, items }` |
138+
| `GET /v1/url/resolve` | brand-URL write path (api-service `resolveUrl`, migration CLI — serenity-docs#25) | canonicalize a raw URL → `{ domain, primary_url, is_valid }` (overlay CR16). `primary_url` strips scheme + `www.` (subdomain + path preserved), `domain` is the registrable apex; garbage input → empty strings + `is_valid:false` at HTTP `200` (never an error). `primary_url` is `required` — a MISSING param `400`s; an EMPTY value falls through to `200 is_valid:false` (Counterfact enforces presence, not the spec's `minLength`). Mock apex = last-two-labels (a multi-part suffix like `example.co.uk``co.uk` is a documented simplification vs live's PSL) |
138139
| `GET /v1/workspaces/{id}/brand-topics` | `getBrandTopics` | top-level array `[{ topic, volume, prompts }]`; `domain` + `country` are `required` query params — omitting either → `400` (enforced by request validation, matching live) |
139140
| `PUT /v1/workspaces/{id}/projects/{project_id}/ci/competitors` | `updateCiCompetitors` | full replace → `{ ci_competitors }` |
140141
| `GET /v2/workspaces/{id}/projects/{project_id}/aio/init_status` | `getInitStatus` | `{ initialized }`. **Live route is `/v2`** — the vendored swagger's `/v1` path 404s (overlay CR8, verified live across 4 projects). The api-service consumer still calls `/v1` today (a pre-existing bug — it degrades to `initialized: null`). |

packages/spacecat-shared-project-engine-client/mock/context.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import { LANGUAGE_CATALOG } from './language-catalog.js';
4747
import { AI_MODEL_CATALOG } from './ai-model-catalog.js';
4848
import { tagId } from './tag-id.js';
4949
import { parentIdField } from './parent-id.js';
50+
import { resolveUrl } from './url-resolve.js';
5051
import { SEEDS, DEFAULT_SEED } from './seeds.js';
5152

5253
/**
@@ -104,6 +105,11 @@ export class Context {
104105
// `PATCH /aio/tags/{tag_id}` — share one coerce-then-spread definition and can't drift, the
105106
// same `$.context` lib-helper convention as `tagId` above.
106107
this.parentIdField = parentIdField;
108+
// The URL canonicalizer (mock/url-resolve.js). Exposed so the `GET /v1/url/resolve` route
109+
// computes the normalized `{ domain, primary_url, is_valid }` through one pure, unit-tested
110+
// function rather than inline in the coverage-excluded handler — same `$.context` lib-helper
111+
// convention as `tagId`. The consumer resolves a raw brand URL through this before writing it.
112+
this.resolveUrl = resolveUrl;
107113
}
108114

109115
/**
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
/**
14+
* Static handler for GET /v1/url/resolve — the URL-canonicalization endpoint the consumer
15+
* (spacecat-api-service brand-URL write path + the Serenity migration CLI) calls before writing a
16+
* brand URL, so the stored value matches the canonical benchmark Semrush already holds. Added to
17+
* the spec by overlay CR16. Live shape: `{ domain, primary_url, is_valid }` (verified 2026-07-03
18+
* against prod — serenity-docs#25 §0). The canonicalization lives in `mock/url-resolve.js` (exposed
19+
* as `$.context.resolveUrl`) so it is unit-tested on its own; the empty/invalid default lives in
20+
* the factory (`createUrlResolveMock`), so a garbage input returns `{ domain: '', primary_url: '',
21+
* is_valid: false }` with HTTP 200 — never an error. A MISSING `primary_url` 400s at request
22+
* validation (the required query param) before this handler runs; an EMPTY value falls through here
23+
* and returns 200 is_valid:false (Counterfact enforces presence but not the spec's minLength:1, so
24+
* this one edge diverges from live's empty-400 — benign, the consumer keys off is_valid).
25+
* Materialized into `.counterfact/routes/` by the mock runner; excluded from coverage.
26+
*/
27+
28+
/** GET — canonicalize `primary_url` to `{ domain, primary_url, is_valid }` via the factory. */
29+
export function GET($) {
30+
const overrides = $.context.resolveUrl($.query.primary_url);
31+
return $.response[200].json($.context.factories.createUrlResolveMock(overrides));
32+
}

packages/spacecat-shared-project-engine-client/mock/factories.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
/** @typedef {Schemas['http_server.BasicResponse']} BasicResponse */
4242
/** @typedef {Schemas['model.AIOProjectInitializedResponse']} InitStatus */
4343
/** @typedef {Schemas['model.CICompetitor']} CiCompetitor */
44+
/** @typedef {Schemas['model.UrlResolveResponse']} UrlResolve */
4445

4546
import { isoForLanguageId } from './language-catalog.js';
4647
import { findCatalogEntryByKey } from './ai-model-catalog.js';
@@ -428,3 +429,18 @@ export const createCiCompetitorMock = (overrides = {}) => ({
428429
color: '',
429430
...overrides,
430431
});
432+
433+
/**
434+
* A URL-resolve result (`UrlResolveResponse`) — the `GET /v1/url/resolve` body. The DEFAULT is the
435+
* live invalid/unresolvable shape (`{ domain: '', primary_url: '', is_valid: false }`, HTTP 200),
436+
* so the route handler passes the {@link resolveUrl} overrides for a valid input and nothing (→
437+
* this empty default) for an invalid one. All three fields are required by the schema (CR16).
438+
* @param {Partial<UrlResolve>} [overrides]
439+
* @returns {UrlResolve}
440+
*/
441+
export const createUrlResolveMock = (overrides = {}) => ({
442+
domain: '',
443+
primary_url: '',
444+
is_valid: false,
445+
...overrides,
446+
});
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/*
2+
* Copyright 2026 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// @ts-check
14+
15+
/**
16+
* The URL-canonicalization the live Semrush Project Engine `GET /v1/url/resolve` performs — the
17+
* single source of truth behind the `mock/counterfact/routes/v1/url/resolve.js` handler, exposed on
18+
* the per-request context as `context.resolveUrl` (every route reads its lib helpers through
19+
* `$.context`, never an import — see {@link Context}). Kept a pure function so it is unit-tested on
20+
* its own (the route handler is coverage-excluded), the same convention as {@link tagId} /
21+
* `parentIdField`.
22+
*
23+
* Live contract (verified 2026-07-03 against prod `adobe-hackathon.semrush.com` — serenity-docs#25
24+
* §0): `primary_url` strips the scheme and a leading `www.` but PRESERVES any other subdomain and
25+
* the path (`http://www.lovesac.com/products` → `lovesac.com/products`; `https://blog.hubspot.com`
26+
* → `blog.hubspot.com`); `domain` is the registrable apex, stripping the subdomain too
27+
* (`blog.hubspot.com` → `hubspot.com`). For unresolvable/garbage input the live API returns
28+
* `{ domain: '', primary_url: '', is_valid: false }` with HTTP **200** (not an error) — the factory
29+
* default {@link createUrlResolveMock} IS that empty/invalid shape, so this returns only the
30+
* OVERRIDES for a valid input and an empty object (→ defaults) for an invalid one.
31+
*
32+
* MOCK SIMPLIFICATION: the apex is the last two dot-labels, so a multi-part public suffix
33+
* (`example.co.uk`) collapses to `co.uk` rather than `example.co.uk`. The live API uses the real
34+
* Public Suffix List. Faithful for the single-suffix domains the consumer resolves in practice
35+
* (`lovesac.com`, `hubspot.com`); documented here rather than pulling a PSL dependency into it.
36+
*
37+
* @param {unknown} primaryUrl the raw `primary_url` query value
38+
* @returns {{ domain: string, primary_url: string, is_valid: boolean } | {}} the resolve overrides
39+
* for a valid URL, or `{}` (→ the empty/invalid factory default) for unresolvable input
40+
*/
41+
export const resolveUrl = (primaryUrl) => {
42+
const raw = typeof primaryUrl === 'string' ? primaryUrl.trim() : '';
43+
if (!raw) {
44+
return {};
45+
}
46+
47+
let parsed;
48+
try {
49+
// A scheme-less input (`www.lovesac.com`) is not a valid URL on its own, so prepend `https://`
50+
// for parsing; an input that already carries a scheme is parsed as-is (its scheme is dropped
51+
// from the output regardless).
52+
const hasScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(raw);
53+
parsed = new URL(hasScheme ? raw : `https://${raw}`);
54+
} catch {
55+
return {};
56+
}
57+
58+
let host = parsed.hostname.toLowerCase();
59+
if (host.startsWith('www.')) {
60+
host = host.slice(4);
61+
}
62+
63+
const labels = host.split('.');
64+
const tld = labels[labels.length - 1];
65+
// Require a dotted hostname whose last label is an alphabetic TLD (>=2 letters). This rejects a
66+
// bare single-label host, an empty label (`foo..com`), and an IP literal (`192.168.0.1`, whose
67+
// last label is numeric) — all of which the live API returns `is_valid: false` for.
68+
if (labels.length < 2 || labels.some((label) => label === '') || !/^[a-z]{2,}$/.test(tld)) {
69+
return {};
70+
}
71+
72+
// Live preserves the path but drops a bare `/`; query/hash are not part of a brand URL.
73+
const path = parsed.pathname === '/' ? '' : parsed.pathname;
74+
return {
75+
domain: labels.slice(-2).join('.'),
76+
primary_url: `${host}${path}`,
77+
is_valid: true,
78+
};
79+
};

packages/spacecat-shared-project-engine-client/python/serenity_project_engine/__init__.py

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/spacecat-shared-project-engine-client/python/serenity_project_engine/aiseo.py

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/spacecat-shared-project-engine-client/python/serenity_project_engine/http_server.py

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/spacecat-shared-project-engine-client/python/serenity_project_engine/model.py

Lines changed: 8 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/spacecat-shared-project-engine-client/spec/overlays/corrections.yaml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,15 @@ info:
8181
# `parent_id: null` to promote a child tag to a root (verified 2026-07-02 against prod). The
8282
# vendored swagger types it as a plain string, so request validation rejected the literal
8383
# null. Remove if Semrush documents the nullability upstream.
84+
# CR16 Add GET /v1/url/resolve — the URL-canonicalization endpoint the live Semrush frontend calls
85+
# before writing a brand URL, absent from the vendored spec. Live: GET
86+
# /v1/url/resolve?primary_url=<raw> -> 200 { domain, primary_url, is_valid } (verified
87+
# 2026-07-03 against prod adobe-hackathon.semrush.com — serenity-docs#25 §0). `primary_url` is
88+
# the canonical brand-URL value (scheme + `www.` stripped, subdomain + path PRESERVED);
89+
# `domain` is the registrable apex (subdomain stripped too); `is_valid` is false — with EMPTY
90+
# `domain`/`primary_url` strings, still HTTP 200 — for unresolvable/garbage input. Consumers
91+
# resolve a raw URL through this before writing it as a brand URL so the stored value matches
92+
# the canonical benchmark Semrush already holds. Remove when Semrush ships the path upstream.
8493
#
8594
# Freshness gate: test/overlay.test.js asserts every action above still APPLIES (matches >0
8695
# nodes) and is still NECESSARY (changes the converted vendored swagger) — so a re-vendored
@@ -415,3 +424,63 @@ actions:
415424
update:
416425
properties:
417426
parent_id: { type: string, nullable: true }
427+
428+
# ── CR16: Add GET /v1/url/resolve ────────────────────────────────────────
429+
# Live: 200 -> { domain, primary_url, is_valid }. Verified 2026-07-03 against prod
430+
# (serenity-docs#25 §0). All three fields are ALWAYS present — even the is_valid:false case returns
431+
# empty `domain`/`primary_url` strings rather than omitting them — so all three are marked required
432+
# (the same faithful-`required` philosophy as CR5: the mock's handler always populates them and the
433+
# live API always returns them). The query param is `required` + `minLength: 1` to document the live
434+
# contract (missing OR empty → 400). Counterfact's request validation enforces the `required`
435+
# presence (a MISSING param 400s, as with the brand-topics params) but not the string `minLength`,
436+
# so an EMPTY value falls through to the handler, which returns 200 is_valid:false — the one benign
437+
# mock divergence from live's empty-400, documented on the route handler.
438+
- target: "$.components.schemas"
439+
description: "CR16: add the UrlResolveResponse schema"
440+
update:
441+
"model.UrlResolveResponse":
442+
type: object
443+
required: [domain, primary_url, is_valid]
444+
properties:
445+
domain: { type: string }
446+
primary_url: { type: string }
447+
is_valid: { type: boolean }
448+
449+
- target: "$.paths"
450+
description: "CR16: add the GET /v1/url/resolve path"
451+
update:
452+
"/v1/url/resolve":
453+
get:
454+
summary: "Resolve a URL to its canonical brand-URL form"
455+
# Folded scalar (>-): authored multi-line for review, emitted as one line.
456+
description: >-
457+
Canonicalize a raw URL to the form Semrush stores as a brand URL. `primary_url` strips
458+
the scheme and a leading `www.` but preserves any other subdomain and the path;
459+
`domain` is the registrable apex (subdomain stripped too). For unresolvable or garbage
460+
input `is_valid` is false and `domain`/`primary_url` are empty strings — still HTTP 200,
461+
so consumers MUST check `is_valid` and never write the empty value.
462+
operationId: "resolve-url"
463+
tags: ["AIO"]
464+
parameters:
465+
- { name: primary_url, in: query, required: true, schema: { type: string, minLength: 1 } }
466+
responses:
467+
"200":
468+
description: "OK"
469+
content:
470+
application/json:
471+
schema:
472+
$ref: "#/components/schemas/model.UrlResolveResponse"
473+
"400":
474+
description: "Bad Request (missing or empty primary_url)"
475+
content:
476+
application/json:
477+
schema:
478+
$ref: "#/components/schemas/http_server.BasicResponse"
479+
"401":
480+
description: "Unauthorized"
481+
content:
482+
application/json:
483+
schema:
484+
$ref: "#/components/schemas/http_server.BasicResponse"
485+
security:
486+
- imsBearer: []

0 commit comments

Comments
 (0)