Skip to content

Commit 5c96dfe

Browse files
fix(api): simplify endpoint resolution and fail fast on catalog generation
1 parent e999902 commit 5c96dfe

18 files changed

+10177
-10193
lines changed

scripts/generate-api-catalog.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,10 +1408,9 @@ async function main() {
14081408

14091409
for (const source of specSources) {
14101410
if (!fs.existsSync(source.specFile)) {
1411-
console.error(
1412-
`WARNING: spec file not found for ${source.repoName}: ${source.specFile} — skipping`,
1411+
throw new Error(
1412+
`spec file not found for ${source.repoName}: ${source.specFile}`,
14131413
);
1414-
continue;
14151414
}
14161415

14171416
try {
@@ -1445,8 +1444,8 @@ async function main() {
14451444
);
14461445
} catch (error) {
14471446
const message = error instanceof Error ? error.message : String(error);
1448-
console.error(
1449-
`WARNING: failed processing ${source.repoName} (${source.specVersion}): ${message}`,
1447+
throw new Error(
1448+
`failed processing ${source.repoName} (${source.specVersion}): ${message}`,
14501449
);
14511450
}
14521451
}

src/cli/commands/api.ts

Lines changed: 180 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -290,28 +290,164 @@ function isHttpMethod(value: string): value is HttpMethod {
290290
return VALID_METHODS.includes(value as HttpMethod);
291291
}
292292

293-
function stripApiHost(endpoint: string): string {
294-
return endpoint.replace(/^https:\/\/api\.godaddy\.com/i, "");
293+
const ABSOLUTE_HTTP_URL_PATTERN = /^https?:\/\//i;
294+
const TRUSTED_API_HOSTS = new Set(["api.godaddy.com", "api.ote-godaddy.com"]);
295+
296+
interface ParsedEndpointInput {
297+
callEndpoint: string;
298+
catalogPathCandidates: string[];
299+
absoluteUrl: URL | null;
300+
isTrustedAbsolute: boolean;
301+
invalidAbsoluteUrl: boolean;
295302
}
296303

297-
function normalizeRelativeEndpoint(endpoint: string): string {
298-
const stripped = stripApiHost(endpoint.trim());
299-
if (stripped.length === 0) return "/";
300-
return stripped.startsWith("/") ? stripped : `/${stripped}`;
304+
function parseAbsoluteHttpUrl(value: string): URL | null {
305+
try {
306+
const parsed = new URL(value.trim());
307+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
308+
return null;
309+
}
310+
return parsed;
311+
} catch {
312+
return null;
313+
}
314+
}
315+
316+
function normalizeCatalogPath(pathValue: string): string {
317+
const pathOnly = pathValue.split(/[?#]/, 1)[0] || "/";
318+
const withLeadingSlash = pathOnly.startsWith("/") ? pathOnly : `/${pathOnly}`;
319+
320+
if (withLeadingSlash.length > 1 && withLeadingSlash.endsWith("/")) {
321+
return withLeadingSlash.slice(0, -1);
322+
}
323+
324+
return withLeadingSlash;
301325
}
302326

303-
function catalogPathCandidates(endpoint: string): string[] {
304-
const relative = normalizeRelativeEndpoint(endpoint);
305-
const candidates = [relative];
327+
function buildCatalogPathCandidates(pathValue: string): string[] {
328+
const normalizedPath = normalizeCatalogPath(pathValue);
329+
const candidates = [normalizedPath];
306330

307-
const commercePrefixMatch = relative.match(/^\/v\d+\/commerce(\/.*)$/i);
331+
const commercePrefixMatch = normalizedPath.match(/^\/v\d+\/commerce(\/.*)$/i);
308332
if (commercePrefixMatch?.[1]) {
309333
candidates.push(commercePrefixMatch[1]);
310334
}
311335

312336
return [...new Set(candidates)];
313337
}
314338

339+
function parseEndpointInput(endpoint: string): ParsedEndpointInput {
340+
const trimmed = endpoint.trim();
341+
342+
if (trimmed.length === 0) {
343+
return {
344+
callEndpoint: "/",
345+
catalogPathCandidates: ["/"],
346+
absoluteUrl: null,
347+
isTrustedAbsolute: false,
348+
invalidAbsoluteUrl: false,
349+
};
350+
}
351+
352+
if (ABSOLUTE_HTTP_URL_PATTERN.test(trimmed)) {
353+
const absoluteUrl = parseAbsoluteHttpUrl(trimmed);
354+
if (!absoluteUrl) {
355+
return {
356+
callEndpoint: trimmed,
357+
catalogPathCandidates: buildCatalogPathCandidates(trimmed),
358+
absoluteUrl: null,
359+
isTrustedAbsolute: false,
360+
invalidAbsoluteUrl: true,
361+
};
362+
}
363+
364+
const isTrustedAbsolute =
365+
absoluteUrl.protocol === "https:" &&
366+
TRUSTED_API_HOSTS.has(absoluteUrl.hostname.toLowerCase());
367+
368+
const relativePath = `${absoluteUrl.pathname || "/"}${absoluteUrl.search}${absoluteUrl.hash}`;
369+
370+
return {
371+
callEndpoint: isTrustedAbsolute ? relativePath : trimmed,
372+
catalogPathCandidates: buildCatalogPathCandidates(
373+
absoluteUrl.pathname || "/",
374+
),
375+
absoluteUrl,
376+
isTrustedAbsolute,
377+
invalidAbsoluteUrl: false,
378+
};
379+
}
380+
381+
const callEndpoint = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
382+
return {
383+
callEndpoint,
384+
catalogPathCandidates: buildCatalogPathCandidates(callEndpoint),
385+
absoluteUrl: null,
386+
isTrustedAbsolute: false,
387+
invalidAbsoluteUrl: false,
388+
};
389+
}
390+
391+
function resolveCatalogEndpointEffect(
392+
method: HttpMethod,
393+
methodProvided: boolean,
394+
pathCandidates: string[],
395+
fallbackEndpoint: string,
396+
): Effect.Effect<{
397+
method: HttpMethod;
398+
endpoint: string;
399+
catalogMatch?: { domain: CatalogDomain; endpoint: CatalogEndpoint };
400+
}> {
401+
return Effect.gen(function* () {
402+
let catalogMatch:
403+
| { domain: CatalogDomain; endpoint: CatalogEndpoint }
404+
| undefined;
405+
let matchedPath: string | undefined;
406+
407+
if (methodProvided) {
408+
for (const candidatePath of pathCandidates) {
409+
const byPath = yield* findEndpointByPathEffect(method, candidatePath);
410+
if (Option.isSome(byPath)) {
411+
catalogMatch = byPath.value;
412+
matchedPath = candidatePath;
413+
break;
414+
}
415+
}
416+
} else {
417+
for (const candidatePath of pathCandidates) {
418+
const byAnyMethod = yield* findEndpointByAnyMethodEffect(candidatePath);
419+
if (Option.isSome(byAnyMethod)) {
420+
catalogMatch = byAnyMethod.value;
421+
matchedPath = candidatePath;
422+
break;
423+
}
424+
}
425+
}
426+
427+
let resolvedMethod = method;
428+
let resolvedEndpoint = fallbackEndpoint;
429+
430+
if (catalogMatch) {
431+
if (matchedPath === catalogMatch.endpoint.path) {
432+
resolvedEndpoint = buildCallEndpoint(
433+
catalogMatch.domain,
434+
catalogMatch.endpoint,
435+
);
436+
}
437+
438+
if (!methodProvided && isHttpMethod(catalogMatch.endpoint.method)) {
439+
resolvedMethod = catalogMatch.endpoint.method;
440+
}
441+
}
442+
443+
return {
444+
method: resolvedMethod,
445+
endpoint: resolvedEndpoint,
446+
catalogMatch,
447+
};
448+
});
449+
}
450+
315451
function buildCallEndpoint(
316452
domain: CatalogDomain,
317453
endpoint: CatalogEndpoint,
@@ -481,7 +617,8 @@ const apiDescribe = Command.make(
481617
Effect.gen(function* () {
482618
const writer = yield* EnvelopeWriter;
483619

484-
const pathCandidates = catalogPathCandidates(endpoint);
620+
const { catalogPathCandidates: pathCandidates } =
621+
parseEndpointInput(endpoint);
485622

486623
// Try exact path lookup first
487624
let result: Option.Option<{
@@ -700,50 +837,43 @@ const apiCall = Command.make(
700837
}
701838

702839
const methodProvided = Option.isSome(config.method);
703-
let method: HttpMethod = methodInput;
704-
let resolvedEndpoint = config.endpoint;
705-
706-
let catalogMatch:
707-
| { domain: CatalogDomain; endpoint: CatalogEndpoint }
708-
| undefined;
709-
710-
const pathCandidates = catalogPathCandidates(config.endpoint);
711-
if (methodProvided) {
712-
for (const candidatePath of pathCandidates) {
713-
const byPath = yield* findEndpointByPathEffect(method, candidatePath);
714-
if (Option.isSome(byPath)) {
715-
catalogMatch = byPath.value;
716-
break;
717-
}
718-
}
719-
} else {
720-
for (const candidatePath of pathCandidates) {
721-
const byAnyMethod =
722-
yield* findEndpointByAnyMethodEffect(candidatePath);
723-
if (Option.isSome(byAnyMethod)) {
724-
catalogMatch = byAnyMethod.value;
725-
break;
726-
}
727-
}
840+
const parsedEndpoint = parseEndpointInput(config.endpoint);
841+
842+
if (parsedEndpoint.invalidAbsoluteUrl) {
843+
return yield* Effect.fail(
844+
new ValidationError({
845+
message: `Invalid endpoint URL: ${config.endpoint}`,
846+
userMessage:
847+
"Endpoint must be a valid URL or a relative path (for example: /v1/domains).",
848+
}),
849+
);
728850
}
729851

730-
if (catalogMatch) {
731-
resolvedEndpoint = buildCallEndpoint(
732-
catalogMatch.domain,
733-
catalogMatch.endpoint,
852+
if (parsedEndpoint.absoluteUrl && !parsedEndpoint.isTrustedAbsolute) {
853+
return yield* Effect.fail(
854+
new ValidationError({
855+
message: `Untrusted endpoint host: ${parsedEndpoint.absoluteUrl.hostname}`,
856+
userMessage:
857+
"Use a relative endpoint path, or a trusted GoDaddy API URL on api.godaddy.com or api.ote-godaddy.com.",
858+
}),
734859
);
860+
}
735861

736-
if (!methodProvided && isHttpMethod(catalogMatch.endpoint.method)) {
737-
method = catalogMatch.endpoint.method;
738-
}
862+
const resolved = yield* resolveCatalogEndpointEffect(
863+
methodInput,
864+
methodProvided,
865+
parsedEndpoint.catalogPathCandidates,
866+
parsedEndpoint.callEndpoint,
867+
);
739868

740-
if (cliConfig.verbosity >= 1) {
741-
process.stderr.write(
742-
`Resolved endpoint to ${catalogMatch.endpoint.method} ${resolvedEndpoint}\n`,
743-
);
744-
}
745-
} else {
746-
resolvedEndpoint = normalizeRelativeEndpoint(config.endpoint);
869+
const method = resolved.method;
870+
const resolvedEndpoint = resolved.endpoint;
871+
const catalogMatch = resolved.catalogMatch;
872+
873+
if (catalogMatch && cliConfig.verbosity >= 1) {
874+
process.stderr.write(
875+
`Resolved endpoint to ${catalogMatch.endpoint.method} ${resolvedEndpoint}\n`,
876+
);
747877
}
748878

749879
const fields = yield* parseFieldsEffect(

0 commit comments

Comments
 (0)