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.
This commit is contained in:
2025-10-22 23:37:36 -05:00
parent abbde855ed
commit 72c88fb719
3 changed files with 256 additions and 117 deletions

View File

@@ -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<string | null>(null);
const [target, setTarget] = useState<string>("");
const [debouncedTarget] = useDebouncedValue(target, 150);
const [debouncedTarget] = useDebouncedValue(target, 75);
const [uriType, setUriType] = useState<Maybe<TargetType>>(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<string>("");
const getTypeEasy = useCallback(async (target: string): Promise<Result<TargetType, Error>> => {
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<IpNetwork>(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<IpNetwork>(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<Domain>(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<AutonomousNumber>(
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<Domain>(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({

View File

@@ -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<Result<RdapQueryResult, Error>> {
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<IpNetwork>(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<IpNetwork>(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<Domain>(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<AutonomousNumber>(
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<Domain>(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."));
}
}

View File

@@ -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<Register>
): Promise<Result<TargetType, Error>> {
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<Register>
): Promise<Result<true, string>> {
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}`;
}