From 72c88fb71993c34c4bbb62b41233ba2f28d62708 Mon Sep 17 00:00:00 2001 From: Xevion Date: Wed, 22 Oct 2025 23:37:36 -0500 Subject: [PATCH] refactor: extract RDAP query and type detection into separate services Extracted RDAP query execution logic from useLookup hook into dedicated rdap-query.ts service, improving code organization and reusability. Created type-detection.ts service to centralize target type detection and validation logic. Added HttpSecurityError class for better error handling of HTTP security warnings. Reduced debounce delay from 150ms to 75ms for improved responsiveness. --- src/rdap/hooks/useLookup.tsx | 142 +++++------------------- src/rdap/services/rdap-query.ts | 166 ++++++++++++++++++++++++++++ src/rdap/services/type-detection.ts | 65 +++++++++++ 3 files changed, 256 insertions(+), 117 deletions(-) create mode 100644 src/rdap/services/rdap-query.ts create mode 100644 src/rdap/services/type-detection.ts diff --git a/src/rdap/hooks/useLookup.tsx b/src/rdap/hooks/useLookup.tsx index 74a58a7..9330b20 100644 --- a/src/rdap/hooks/useLookup.tsx +++ b/src/rdap/hooks/useLookup.tsx @@ -1,19 +1,17 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useDebouncedValue } from "@mantine/hooks"; -import { getType, validateInputForType } from "@/rdap/utils"; -import type { AutonomousNumber, Domain, IpNetwork, SubmitProps, TargetType } from "@/rdap/schemas"; -import { - AutonomousNumberSchema, - DomainSchema, - IpNetworkSchema, - RootRegistryEnum, -} from "@/rdap/schemas"; -import { truncated } from "@/lib/utils"; +import type { SubmitProps, TargetType } from "@/rdap/schemas"; +import { RootRegistryEnum } from "@/rdap/schemas"; import type { ParsedGeneric } from "@/rdap/components/Generic"; import { Maybe, Result } from "true-myth"; import { loadBootstrap, getRegistry } from "@/rdap/services/registry"; -import { getRegistryURL } from "@/rdap/services/url-resolver"; -import { getAndParse } from "@/rdap/services/rdap-api"; +import { + detectTargetType, + validateTargetType, + generateValidationWarning, + generateBootstrapWarning, +} from "@/rdap/services/type-detection"; +import { executeRdapQuery, HttpSecurityError } from "@/rdap/services/rdap-query"; export type WarningHandler = (warning: { message: string }) => void; export type MetaParsedGeneric = { @@ -22,13 +20,10 @@ export type MetaParsedGeneric = { completeTime: Date; }; -// An array of schemas to try and parse unknown JSON data with. -const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema]; - const useLookup = (warningHandler?: WarningHandler) => { const [error, setError] = useState(null); const [target, setTarget] = useState(""); - const [debouncedTarget] = useDebouncedValue(target, 150); + const [debouncedTarget] = useDebouncedValue(target, 75); const [uriType, setUriType] = useState>(Maybe.nothing()); // Used by a callback on LookupInput to forcibly set the type of the lookup. @@ -38,7 +33,7 @@ const useLookup = (warningHandler?: WarningHandler) => { const repeatableRef = useRef(""); const getTypeEasy = useCallback(async (target: string): Promise> => { - return getType(target, getRegistry); + return detectTargetType(target, getRegistry); }, []); useEffect(() => { @@ -67,9 +62,8 @@ const useLookup = (warningHandler?: WarningHandler) => { await loadBootstrap(registryUri.data); } catch (e) { if (warningHandler != undefined) { - const message = e instanceof Error ? `(${truncated(e.message, 15)})` : "."; warningHandler({ - message: `Failed to preload registry${message}`, + message: generateBootstrapWarning(e), }); } } @@ -93,12 +87,12 @@ const useLookup = (warningHandler?: WarningHandler) => { targetType = currentType; // Validate the input matches the selected type - const validation = await validateInputForType(target, currentType, getRegistry); + const validation = await validateTargetType(target, currentType, getRegistry); if (validation.isErr) { // Show warning but proceed with user's selection if (warningHandler != undefined) { warningHandler({ - message: `Warning: ${validation.error}. Proceeding with selected type "${currentType}".`, + message: generateValidationWarning(validation.error, currentType), }); } } @@ -117,105 +111,19 @@ const useLookup = (warningHandler?: WarningHandler) => { targetType = detectedType.value; } - // Prepare query parameters for RDAP requests - const queryParams = { jsContact: requestJSContact, followReferral }; + // Execute the RDAP query using the extracted service + const result = await executeRdapQuery(target, targetType, { + requestJSContact, + followReferral, + repeatableUrl: repeatableRef.current, + }); - switch (targetType) { - // Block scoped case to allow url const reuse - case "ip4": { - await loadBootstrap("ip4"); - const url = getRegistryURL(targetType, target, queryParams); - const result = await getAndParse(url, IpNetworkSchema, followReferral); - if (result.isErr) return Result.err(result.error); - return Result.ok({ data: result.value, url }); - } - case "ip6": { - await loadBootstrap("ip6"); - const url = getRegistryURL(targetType, target, queryParams); - const result = await getAndParse(url, IpNetworkSchema, followReferral); - if (result.isErr) return Result.err(result.error); - return Result.ok({ data: result.value, url }); - } - case "domain": { - await loadBootstrap("domain"); - const url = getRegistryURL(targetType, target, queryParams); - - // HTTP - if (url.startsWith("http://") && url != repeatableRef.current) { - repeatableRef.current = url; - return Result.err( - new Error( - "The registry this domain belongs to uses HTTP, which is not secure. " + - "In order to prevent a cryptic error from appearing due to mixed active content, " + - "or worse, a CORS error, this lookup has been blocked. Try again to force the lookup." - ) - ); - } - const result = await getAndParse(url, DomainSchema, followReferral); - if (result.isErr) return Result.err(result.error); - - return Result.ok({ data: result.value, url }); - } - case "autnum": { - await loadBootstrap("autnum"); - const url = getRegistryURL(targetType, target, queryParams); - const result = await getAndParse( - url, - AutonomousNumberSchema, - followReferral - ); - if (result.isErr) return Result.err(result.error); - return Result.ok({ data: result.value, url }); - } - case "tld": { - // remove the leading dot - const value = target.startsWith(".") ? target.slice(1) : target; - const params = new URLSearchParams(); - if (requestJSContact) params.append("jsContact", "1"); - if (followReferral) params.append("followReferral", "1"); - const queryString = params.toString(); - const baseUrl = `https://root.rdap.org/domain/${value}`; - const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; - const result = await getAndParse(url, DomainSchema, followReferral); - if (result.isErr) return Result.err(result.error); - return Result.ok({ data: result.value, url }); - } - case "url": { - const response = await fetch(target); - - if (response.status !== 200) - return Result.err( - new Error( - `The URL provided returned a non-200 status code: ${response.status}.` - ) - ); - - const data = await response.json(); - - // Try each schema until one works - for (const schema of schemas) { - const result = schema.safeParse(data); - if (result.success) return Result.ok({ data: result.data, url: target }); - } - - return Result.err(new Error("No schema was able to parse the response.")); - } - case "json": { - try { - const data = JSON.parse(target); - for (const schema of schemas) { - const result = schema.safeParse(data); - if (result.success) return Result.ok({ data: result.data, url: "" }); - } - } catch (e) { - return Result.err(new Error("Invalid JSON format", { cause: e })); - } - } - case "registrar": { - } - default: - return Result.err(new Error("The type detected has not been implemented.")); + // Update repeatable ref if we got an HTTP security error for domain lookups + if (result.isErr && result.error instanceof HttpSecurityError) { + repeatableRef.current = result.error.url; } + + return result; } async function submit({ diff --git a/src/rdap/services/rdap-query.ts b/src/rdap/services/rdap-query.ts new file mode 100644 index 0000000..9461784 --- /dev/null +++ b/src/rdap/services/rdap-query.ts @@ -0,0 +1,166 @@ +import type { AutonomousNumber, Domain, IpNetwork, TargetType } from "@/rdap/schemas"; +import { AutonomousNumberSchema, DomainSchema, IpNetworkSchema } from "@/rdap/schemas"; +import { Result } from "true-myth"; +import { loadBootstrap } from "@/rdap/services/registry"; +import { getRegistryURL } from "@/rdap/services/url-resolver"; +import { getAndParse } from "@/rdap/services/rdap-api"; +import type { ParsedGeneric } from "@/rdap/components/Generic"; + +// An array of schemas to try and parse unknown JSON data with. +const schemas = [DomainSchema, AutonomousNumberSchema, IpNetworkSchema]; + +/** + * Custom error for HTTP security warnings that includes the URL for repeatability. + */ +export class HttpSecurityError extends Error { + constructor( + message: string, + public url: string + ) { + super(message); + this.name = "HttpSecurityError"; + } +} + +export interface RdapQueryOptions { + requestJSContact: boolean; + followReferral: boolean; + /** + * Used to allow repeatable lookups when weird errors happen. + * If provided and matches the generated URL, will skip HTTP validation. + */ + repeatableUrl?: string; +} + +export interface RdapQueryResult { + data: ParsedGeneric; + url: string; +} + +/** + * Execute an RDAP query for a given target and type. + * + * This function handles: + * - Loading bootstrap data for the target type + * - Constructing the appropriate RDAP URL + * - Fetching and parsing the RDAP response + * - Handling special cases (HTTP warnings, URL/JSON parsing, etc.) + * + * @param target - The lookup target (domain, IP, ASN, etc.) + * @param targetType - The type of the target + * @param options - Query options (jsContact, followReferral, repeatableUrl) + * @returns A Result containing the parsed data and URL, or an error + */ +export async function executeRdapQuery( + target: string, + targetType: TargetType, + options: RdapQueryOptions +): Promise> { + if (target == null || target.length == 0) { + return Result.err(new Error("A target must be given in order to execute a lookup.")); + } + + const { requestJSContact, followReferral, repeatableUrl } = options; + + // Prepare query parameters for RDAP requests + const queryParams = { jsContact: requestJSContact, followReferral }; + + switch (targetType) { + // Block scoped case to allow url const reuse + case "ip4": { + await loadBootstrap("ip4"); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse(url, IpNetworkSchema, followReferral); + if (result.isErr) return Result.err(result.error); + return Result.ok({ data: result.value, url }); + } + case "ip6": { + await loadBootstrap("ip6"); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse(url, IpNetworkSchema, followReferral); + if (result.isErr) return Result.err(result.error); + return Result.ok({ data: result.value, url }); + } + case "domain": { + await loadBootstrap("domain"); + const url = getRegistryURL(targetType, target, queryParams); + + // HTTP + if (url.startsWith("http://") && url != repeatableUrl) { + return Result.err( + new HttpSecurityError( + "The registry this domain belongs to uses HTTP, which is not secure. " + + "In order to prevent a cryptic error from appearing due to mixed active content, " + + "or worse, a CORS error, this lookup has been blocked. Try again to force the lookup.", + url + ) + ); + } + const result = await getAndParse(url, DomainSchema, followReferral); + if (result.isErr) return Result.err(result.error); + + return Result.ok({ data: result.value, url }); + } + case "autnum": { + await loadBootstrap("autnum"); + const url = getRegistryURL(targetType, target, queryParams); + const result = await getAndParse( + url, + AutonomousNumberSchema, + followReferral + ); + if (result.isErr) return Result.err(result.error); + return Result.ok({ data: result.value, url }); + } + case "tld": { + // remove the leading dot + const value = target.startsWith(".") ? target.slice(1) : target; + const params = new URLSearchParams(); + if (requestJSContact) params.append("jsContact", "1"); + if (followReferral) params.append("followReferral", "1"); + const queryString = params.toString(); + const baseUrl = `https://root.rdap.org/domain/${value}`; + const url = queryString ? `${baseUrl}?${queryString}` : baseUrl; + const result = await getAndParse(url, DomainSchema, followReferral); + if (result.isErr) return Result.err(result.error); + return Result.ok({ data: result.value, url }); + } + case "url": { + const response = await fetch(target); + + if (response.status !== 200) + return Result.err( + new Error( + `The URL provided returned a non-200 status code: ${response.status}.` + ) + ); + + const data = await response.json(); + + // Try each schema until one works + for (const schema of schemas) { + const result = schema.safeParse(data); + if (result.success) return Result.ok({ data: result.data, url: target }); + } + + return Result.err(new Error("No schema was able to parse the response.")); + } + case "json": { + try { + const data = JSON.parse(target); + for (const schema of schemas) { + const result = schema.safeParse(data); + if (result.success) return Result.ok({ data: result.data, url: "" }); + } + } catch (e) { + return Result.err(new Error("Invalid JSON format", { cause: e })); + } + return Result.err(new Error("No schema was able to parse the JSON.")); + } + case "entity": + case "registrar": + return Result.err(new Error("The type detected has not been implemented.")); + default: + return Result.err(new Error("The type detected has not been implemented.")); + } +} diff --git a/src/rdap/services/type-detection.ts b/src/rdap/services/type-detection.ts new file mode 100644 index 0000000..bb7bec2 --- /dev/null +++ b/src/rdap/services/type-detection.ts @@ -0,0 +1,65 @@ +import type { Register, RootRegistryType, TargetType } from "@/rdap/schemas"; +import { getType, validateInputForType } from "@/rdap/utils"; +import type { Result } from "true-myth"; +import { truncated } from "@/lib/utils"; + +/** + * Detect the target type for a given input string. + * + * This is a wrapper around the `getType` utility function that provides + * a consistent interface for type detection in the application. + * + * @param target - The input string to detect the type for + * @param getRegistry - Function to fetch registry data + * @returns A Result containing the detected TargetType or an error + */ +export async function detectTargetType( + target: string, + getRegistry: (type: RootRegistryType) => Promise +): Promise> { + return getType(target, getRegistry); +} + +/** + * Validate that a given input matches the expected target type. + * + * This is a wrapper around the `validateInputForType` utility function. + * Used to warn users when their explicit type selection doesn't match the input format. + * + * @param target - The input string to validate + * @param targetType - The expected target type + * @param getRegistry - Function to fetch registry data + * @returns A Result containing true if valid, or an error message if invalid + */ +export async function validateTargetType( + target: string, + targetType: TargetType, + getRegistry: (type: RootRegistryType) => Promise +): Promise> { + return validateInputForType(target, targetType, getRegistry); +} + +/** + * Generate a warning message for type validation failures. + * + * @param validationError - The validation error message + * @param selectedType - The type that was selected by the user + * @returns A formatted warning message + */ +export function generateValidationWarning( + validationError: string, + selectedType: TargetType +): string { + return `Warning: ${validationError}. Proceeding with selected type "${selectedType}".`; +} + +/** + * Generate a warning message for bootstrap loading failures. + * + * @param error - The error that occurred + * @returns A formatted warning message + */ +export function generateBootstrapWarning(error: unknown): string { + const message = error instanceof Error ? `(${truncated(error.message, 15)})` : "."; + return `Failed to preload registry${message}`; +}