diff --git a/packages/addresszen/api.test.ts b/packages/addresszen/api.test.ts new file mode 100644 index 000000000..6029c0e28 --- /dev/null +++ b/packages/addresszen/api.test.ts @@ -0,0 +1,109 @@ +import 'dotenv/config'; +import { makeAddresszenRequest } from './client'; +import type { + AutocompleteAddressesResponse, + KeyAvailabilityResponse, + ResolveAddressUsaResponse, + VerifyAddressResponse, +} from './endpoints/types'; +import { AddresszenEndpointOutputSchemas } from './endpoints/types'; + +const TEST_API_KEY = process.env.ADDRESSZEN_API_KEY; +const describeIfApiKey = TEST_API_KEY ? describe : describe.skip; + +describeIfApiKey('Addresszen API Type Tests', () => { + describe('key', () => { + it('keyAvailability returns correct type', async () => { + const response = await makeAddresszenRequest( + `keys/${encodeURIComponent(TEST_API_KEY!)}`, + TEST_API_KEY!, + { method: 'GET', auth: false }, + ); + + AddresszenEndpointOutputSchemas.keyAvailability.parse(response); + expect(response.code).toBe(2000); + expect(typeof response.result.available).toBe('boolean'); + }); + }); + + describe('autocomplete', () => { + it('autocompleteAddresses returns correct type', async () => { + const response = + await makeAddresszenRequest( + 'autocomplete/addresses', + TEST_API_KEY!, + { + method: 'GET', + query: { + q: '10 downing', + }, + }, + ); + + AddresszenEndpointOutputSchemas.autocompleteAddresses.parse(response); + expect(response.code).toBe(2000); + }); + }); + + describe('resolve', () => { + it('resolveAddressUsa returns correct type', async () => { + const suggestions = + await makeAddresszenRequest( + 'autocomplete/addresses', + TEST_API_KEY!, + { + method: 'GET', + query: { q: '1600 Garfield Aliquippa' }, + }, + ); + + const addressId = suggestions.result.hits[0]?.id; + expect(addressId).toBeTruthy(); + + const response = await makeAddresszenRequest( + `autocomplete/addresses/${encodeURIComponent(addressId!)}/usa`, + TEST_API_KEY!, + { method: 'GET' }, + ); + + AddresszenEndpointOutputSchemas.resolveAddressUsa.parse(response); + expect(response.code).toBe(2000); + expect(response.result.line_1).toBeTruthy(); + }); + }); + + describe('verify', () => { + it('verifyAddress returns correct type', async () => { + const response = await makeAddresszenRequest( + 'verify/addresses', + TEST_API_KEY!, + { + method: 'POST', + body: { + query: '123 Main St, Springfield, CO 81073', + }, + }, + ); + + AddresszenEndpointOutputSchemas.verifyAddress.parse(response); + expect(response.code).toBe(2000); + }); + + it('verifyAddress with split components returns correct type', async () => { + const response = await makeAddresszenRequest( + 'verify/addresses', + TEST_API_KEY!, + { + method: 'POST', + body: { + query: '123 Main St', + city: 'Springfield', + state: 'CO', + }, + }, + ); + + AddresszenEndpointOutputSchemas.verifyAddress.parse(response); + }); + }); +}); diff --git a/packages/addresszen/client.ts b/packages/addresszen/client.ts new file mode 100644 index 000000000..ab99da666 --- /dev/null +++ b/packages/addresszen/client.ts @@ -0,0 +1,87 @@ +import type { ApiRequestOptions, OpenAPIConfig } from 'corsair/http'; +import { ApiError, request } from 'corsair/http'; + +export class AddresszenAPIError extends Error { + public readonly status?: number; + public readonly statusText?: string; + // Using unknown because Addresszen API error response bodies vary by endpoint + // and error code, making a strict type infeasible without per-endpoint handling. + public readonly body?: unknown; + public readonly retryAfter?: number; + + constructor( + message: string, + public readonly code?: number, + options?: { cause?: Error }, + ) { + super(message, options); + this.name = 'AddresszenAPIError'; + + if (options?.cause instanceof ApiError) { + this.status = options.cause.status; + this.statusText = options.cause.statusText; + this.body = options.cause.body; + this.retryAfter = options.cause.retryAfter; + } + } +} + +const ADDRESSZEN_API_BASE = 'https://api.addresszen.com/v1'; + +/** + * Performs a request to the Addresszen API. + * + * Auth: API key passed via the Authorization header to avoid leaking credentials + * into URL access logs. Addresszen also supports query-string auth, but header + * auth is preferred per their API reference. + */ +export async function makeAddresszenRequest( + endpoint: string, + apiKey: string, + options: { + method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'; + body?: Record; + query?: Record; + /** When false, skip Authorization (public endpoints that identify the key in the path). */ + auth?: boolean; + } = {}, +): Promise { + const { method = 'GET', body, query = {}, auth = true } = options; + const isWrite = method === 'POST' || method === 'PUT' || method === 'PATCH'; + + const config: OpenAPIConfig = { + BASE: ADDRESSZEN_API_BASE, + VERSION: '1.0.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'omit', + TOKEN: undefined, + HEADERS: { + ...(auth ? { Authorization: `api_key="${apiKey}"` } : {}), + ...(isWrite ? { 'Content-Type': 'application/json' } : {}), + }, + }; + + const requestOptions: ApiRequestOptions = { + method, + url: endpoint, + body: isWrite ? body : undefined, + mediaType: isWrite ? 'application/json; charset=utf-8' : undefined, + query, + }; + + try { + return await request(config, requestOptions); + } catch (error) { + if (error instanceof ApiError) { + throw new AddresszenAPIError(error.message, error.status, { + cause: error, + }); + } + if (error instanceof Error) { + throw new AddresszenAPIError(error.message, undefined, { + cause: error, + }); + } + throw new AddresszenAPIError('Unknown error'); + } +} diff --git a/packages/addresszen/endpoints/autocomplete.ts b/packages/addresszen/endpoints/autocomplete.ts new file mode 100644 index 000000000..af5e35d80 --- /dev/null +++ b/packages/addresszen/endpoints/autocomplete.ts @@ -0,0 +1,52 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAddresszenRequest } from '../client'; +import type { AddresszenEndpoints } from '../index'; +import type { AddresszenEndpointOutputs } from './types'; + +/** + * Get address autocomplete suggestions for a partial query. + * + * API: GET /autocomplete/addresses + * Docs: https://docs.addresszen.com/docs/api/find-address + */ +export const addresses: AddresszenEndpoints['autocompleteAddresses'] = async ( + ctx, + input, +) => { + const response = await makeAddresszenRequest< + AddresszenEndpointOutputs['autocompleteAddresses'] + >('autocomplete/addresses', ctx.key, { + method: 'GET', + query: { + q: input.query, + limit: input.limit, + page: input.page, + }, + }); + + if (ctx.db.autocompleteResults) { + try { + const { result, ...rest } = response; + await ctx.db.autocompleteResults.upsertByEntityId(input.query, { + ...rest, + query: input.query, + hits: result.hits, + updatedAt: new Date(), + }); + } catch (error) { + console.warn( + '[addresszen] Failed to save autocomplete results to database:', + error, + ); + } + } + + await logEventFromContext( + ctx, + 'addresszen.autocomplete.addresses', + { query: input.query, hitCount: response.result.hits.length }, + 'completed', + ); + + return response; +}; diff --git a/packages/addresszen/endpoints/index.ts b/packages/addresszen/endpoints/index.ts new file mode 100644 index 000000000..9884a09c4 --- /dev/null +++ b/packages/addresszen/endpoints/index.ts @@ -0,0 +1,22 @@ +import { addresses } from './autocomplete'; +import { availability } from './key'; +import { addressUsa } from './resolve'; +import { address } from './verify'; + +export const Autocomplete = { + addresses, +}; + +export const Verify = { + address, +}; + +export const Key = { + availability, +}; + +export const Resolve = { + addressUsa, +}; + +export * from './types'; diff --git a/packages/addresszen/endpoints/key.ts b/packages/addresszen/endpoints/key.ts new file mode 100644 index 000000000..081578710 --- /dev/null +++ b/packages/addresszen/endpoints/key.ts @@ -0,0 +1,53 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAddresszenRequest } from '../client'; +import type { AddresszenEndpoints } from '../index'; +import type { AddresszenEndpointOutputs } from './types'; + +/** + * Get public information on an API key, including whether it is usable. + * + * API: GET /keys/:key + * Docs: https://docs.addresszen.com/docs/api/key-availability + * + * Addresszen requires the key as the path resource id for this public endpoint; + * there is no header-only variant. Auth header is omitted so the credential is + * not also sent in Authorization. + */ +export const availability: AddresszenEndpoints['keyAvailability'] = async ( + ctx, + _input, +) => { + const response = await makeAddresszenRequest< + AddresszenEndpointOutputs['keyAvailability'] + >(`keys/${encodeURIComponent(ctx.key)}`, ctx.key, { + method: 'GET', + auth: false, + }); + + if (ctx.db.keyAvailability) { + try { + const accountId = await ctx.$getAccountId(); + await ctx.db.keyAvailability.upsertByEntityId(accountId, { + available: response.result.available, + context: response.result.context ?? null, + code: response.code, + message: response.message, + updatedAt: new Date(), + }); + } catch (error) { + console.warn( + '[addresszen] Failed to save key availability to database:', + error, + ); + } + } + + await logEventFromContext( + ctx, + 'addresszen.key.availability', + { available: response.result.available }, + 'completed', + ); + + return response; +}; diff --git a/packages/addresszen/endpoints/resolve.ts b/packages/addresszen/endpoints/resolve.ts new file mode 100644 index 000000000..01cc399a8 --- /dev/null +++ b/packages/addresszen/endpoints/resolve.ts @@ -0,0 +1,49 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAddresszenRequest } from '../client'; +import type { AddresszenEndpoints } from '../index'; +import type { AddresszenEndpointOutputs } from './types'; + +/** + * Resolve an address autocompletion by ID and return the full US-format address. + * + * API: GET /autocomplete/addresses/:address/usa + * Docs: https://docs.addresszen.com/docs/api/retrieve-address + */ +export const addressUsa: AddresszenEndpoints['resolveAddressUsa'] = async ( + ctx, + input, +) => { + const response = await makeAddresszenRequest< + AddresszenEndpointOutputs['resolveAddressUsa'] + >( + `autocomplete/addresses/${encodeURIComponent(input.addressId)}/usa`, + ctx.key, + { method: 'GET' }, + ); + + if (ctx.db.resolvedAddresses) { + try { + const { result, ...rest } = response; + await ctx.db.resolvedAddresses.upsertByEntityId(input.addressId, { + ...rest, + addressId: input.addressId, + address: result, + updatedAt: new Date(), + }); + } catch (error) { + console.warn( + '[addresszen] Failed to save resolved address to database:', + error, + ); + } + } + + await logEventFromContext( + ctx, + 'addresszen.resolve.addressUsa', + { addressId: input.addressId }, + 'completed', + ); + + return response; +}; diff --git a/packages/addresszen/endpoints/types.ts b/packages/addresszen/endpoints/types.ts new file mode 100644 index 000000000..e24eb55b4 --- /dev/null +++ b/packages/addresszen/endpoints/types.ts @@ -0,0 +1,173 @@ +import { z } from 'zod'; + +const AddressSuggestionSchema = z + .object({ + id: z.string(), + suggestion: z.string(), + // urls shape varies by country (US vs UK); provider may return null. + urls: z.record(z.string(), z.unknown()).nullable().optional(), + udprn: z.number().optional(), + }) + .loose(); + +export type AddressSuggestion = z.infer; + +export const AutocompleteAddressesInputSchema = z.object({ + query: z + .string() + .min(1) + .max(150) + .describe('Partial address string to autocomplete'), + limit: z.number().int().positive().optional(), + page: z.number().int().nonnegative().optional(), +}); + +export type AutocompleteAddressesInput = z.infer< + typeof AutocompleteAddressesInputSchema +>; + +export const AutocompleteAddressesResponseSchema = z.object({ + code: z.number(), + message: z.string(), + result: z + .object({ + hits: z.array(AddressSuggestionSchema), + }) + .loose(), +}); + +export type AutocompleteAddressesResponse = z.infer< + typeof AutocompleteAddressesResponseSchema +>; + +export const VerifyAddressInputSchema = z.object({ + query: z + .string() + .min(1) + .describe( + 'Address to verify. Use a full free-form address, or only the first line when city/state or zip_code are provided separately.', + ), + zip_code: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + context: z + .string() + .optional() + .describe('Optional metadata tag stored with the lookup'), +}); + +export type VerifyAddressInput = z.infer; + +const VerifyResultSchema = z + .object({ + query: z.string(), + query_city: z.string().optional(), + query_state: z.string().optional(), + query_zip_code: z.string().optional(), + // match is a oneOf across US/UK address object schemas in the OpenAPI spec. + match: z.unknown().nullable().optional(), + count: z.number().optional(), + fit: z.number().optional(), + confidence: z.number().optional(), + // match_information structure depends on the matched address type. + match_information: z.unknown().optional(), + address_line_one: z.string().optional(), + address_line_two: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + zip_code: z.string().optional(), + country_iso_2: z.string().optional(), + }) + .loose(); + +export const VerifyAddressResponseSchema = z.object({ + code: z.number(), + message: z.string(), + result: VerifyResultSchema, +}); + +export type VerifyAddressResponse = z.infer; + +export const KeyAvailabilityInputSchema = z.object({}); + +export type KeyAvailabilityInput = z.infer; + +export const KeyAvailabilityResponseSchema = z.object({ + code: z.number(), + message: z.string(), + result: z + .object({ + available: z.boolean(), + context: z.string().optional(), + // Country/context catalog; large and varies by key entitlements. + contexts: z.array(z.record(z.string(), z.unknown())).optional(), + }) + .loose(), +}); + +export type KeyAvailabilityResponse = z.infer< + typeof KeyAvailabilityResponseSchema +>; + +export const ResolveAddressUsaInputSchema = z.object({ + addressId: z + .string() + .min(1) + .describe( + 'Address suggestion ID from autocomplete (e.g. usps_X130125796|1600||1933)', + ), +}); + +export type ResolveAddressUsaInput = z.infer< + typeof ResolveAddressUsaInputSchema +>; + +export const ResolveAddressUsaResponseSchema = z.object({ + code: z.number(), + message: z.string(), + // US-format resolved address fields vary across datasets; keep loose. + result: z + .object({ + id: z.string().optional(), + line_1: z.string().optional(), + line_2: z.string().optional(), + city: z.string().optional(), + state: z.string().optional(), + state_abbreviation: z.string().optional(), + zip_code: z.string().optional(), + country_iso_2: z.string().optional(), + }) + .loose(), +}); + +export type ResolveAddressUsaResponse = z.infer< + typeof ResolveAddressUsaResponseSchema +>; + +export type AddresszenEndpointInputs = { + autocompleteAddresses: AutocompleteAddressesInput; + verifyAddress: VerifyAddressInput; + keyAvailability: KeyAvailabilityInput; + resolveAddressUsa: ResolveAddressUsaInput; +}; + +export type AddresszenEndpointOutputs = { + autocompleteAddresses: AutocompleteAddressesResponse; + verifyAddress: VerifyAddressResponse; + keyAvailability: KeyAvailabilityResponse; + resolveAddressUsa: ResolveAddressUsaResponse; +}; + +export const AddresszenEndpointInputSchemas = { + autocompleteAddresses: AutocompleteAddressesInputSchema, + verifyAddress: VerifyAddressInputSchema, + keyAvailability: KeyAvailabilityInputSchema, + resolveAddressUsa: ResolveAddressUsaInputSchema, +} as const; + +export const AddresszenEndpointOutputSchemas = { + autocompleteAddresses: AutocompleteAddressesResponseSchema, + verifyAddress: VerifyAddressResponseSchema, + keyAvailability: KeyAvailabilityResponseSchema, + resolveAddressUsa: ResolveAddressUsaResponseSchema, +} as const; diff --git a/packages/addresszen/endpoints/verify.ts b/packages/addresszen/endpoints/verify.ts new file mode 100644 index 000000000..4fc87a33a --- /dev/null +++ b/packages/addresszen/endpoints/verify.ts @@ -0,0 +1,62 @@ +import { logEventFromContext } from 'corsair/core'; +import { makeAddresszenRequest } from '../client'; +import type { AddresszenEndpoints } from '../index'; +import type { AddresszenEndpointOutputs } from './types'; + +/** + * Verify and standardize a US address using USPS CASS. + * + * API: POST /verify/addresses + * Docs: https://docs.addresszen.com/docs/api/address-verify + */ +export const address: AddresszenEndpoints['verifyAddress'] = async ( + ctx, + input, +) => { + const { context, ...body } = input; + + const response = await makeAddresszenRequest< + AddresszenEndpointOutputs['verifyAddress'] + >('verify/addresses', ctx.key, { + method: 'POST', + query: context ? { context } : undefined, + body, + }); + + if (ctx.db.verifiedAddresses) { + try { + // ponytail: JSON key avoids `|` collisions in free-form address inputs + const entityId = JSON.stringify([ + input.query, + input.city ?? null, + input.state ?? null, + input.zip_code ?? null, + input.context ?? null, + ]); + + await ctx.db.verifiedAddresses.upsertByEntityId(entityId, { + ...response, + query: input.query, + city: input.city ?? null, + state: input.state ?? null, + zipCode: input.zip_code ?? null, + context: input.context ?? null, + updatedAt: new Date(), + }); + } catch (error) { + console.warn( + '[addresszen] Failed to save verified address to database:', + error, + ); + } + } + + await logEventFromContext( + ctx, + 'addresszen.verify.address', + { query: input.query }, + 'completed', + ); + + return response; +}; diff --git a/packages/addresszen/error-handlers.ts b/packages/addresszen/error-handlers.ts new file mode 100644 index 000000000..29a179988 --- /dev/null +++ b/packages/addresszen/error-handlers.ts @@ -0,0 +1,69 @@ +import type { CorsairErrorHandler } from 'corsair/core'; +import type { AddresszenAPIError } from './client'; + +/** + * Helper to extract the HTTP status from an error. + * Works with AddresszenAPIError (which copies status from ApiError) + * and any error that exposes a numeric `status` property. + */ +function getStatus(error: Error): number | undefined { + return (error as Partial).status; +} + +/** + * Helper to extract the Retry-After value (in ms) from an error. + */ +function getRetryAfter(error: Error): number | undefined { + return (error as Partial).retryAfter; +} + +export const errorHandlers = { + RATE_LIMIT_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 429) return true; + const msg = error.message.toLowerCase(); + return msg.includes('429') || msg.includes('rate limit'); + }, + handler: async (error: Error) => ({ + maxRetries: 3, + retryStrategy: 'exponential_backoff' as const, + headersRetryAfterMs: getRetryAfter(error), + }), + }, + AUTH_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 401) return true; + const msg = error.message.toLowerCase(); + return ( + msg.includes('invalid key') || + msg.includes('unauthorized') || + msg.includes('401') + ); + }, + handler: async () => ({ maxRetries: 0 }), + }, + NOT_FOUND_ERROR: { + match: (error: Error) => { + if (getStatus(error) === 404) return true; + const msg = error.message.toLowerCase(); + return msg.includes('404') || msg.includes('not found'); + }, + handler: async () => ({ maxRetries: 0 }), + }, + SERVER_ERROR: { + match: (error: Error) => { + const status = getStatus(error); + if (status !== undefined && status >= 500) return true; + const msg = error.message.toLowerCase(); + return msg.includes('503') || msg.includes('server error'); + }, + handler: async () => ({ + maxRetries: 2, + retryStrategy: 'exponential_backoff' as const, + }), + }, + DEFAULT: { + match: () => true, + handler: async () => ({ maxRetries: 0 }), + }, +} satisfies CorsairErrorHandler; diff --git a/packages/addresszen/index.ts b/packages/addresszen/index.ts new file mode 100644 index 000000000..454319ea9 --- /dev/null +++ b/packages/addresszen/index.ts @@ -0,0 +1,214 @@ +import type { + AuthTypes, + BindEndpoints, + CorsairEndpoint, + CorsairErrorHandler, + CorsairPlugin, + CorsairPluginContext, + KeyBuilderContext, + PickAuth, + PluginAuthConfig, + PluginPermissionsConfig, + RequiredPluginEndpointMeta, + RequiredPluginEndpointSchemas, +} from 'corsair/core'; +import { AuthMissingError } from 'corsair/core'; +import { Autocomplete, Key, Resolve, Verify } from './endpoints'; +import type { + AddresszenEndpointInputs, + AddresszenEndpointOutputs, +} from './endpoints/types'; +import { + AddresszenEndpointInputSchemas, + AddresszenEndpointOutputSchemas, +} from './endpoints/types'; +import { errorHandlers } from './error-handlers'; +import { AddresszenSchema } from './schema'; + +export type AddresszenPluginOptions = { + authType?: PickAuth<'api_key'>; + key?: string; + hooks?: InternalAddresszenPlugin['hooks']; + errorHandlers?: CorsairErrorHandler; + permissions?: PluginPermissionsConfig; +}; + +export type AddresszenContext = CorsairPluginContext< + typeof AddresszenSchema, + AddresszenPluginOptions +>; + +export type AddresszenKeyBuilderContext = + KeyBuilderContext; + +export type AddresszenBoundEndpoints = BindEndpoints< + typeof addresszenEndpointsNested +>; + +type AddresszenEndpoint = + CorsairEndpoint< + AddresszenContext, + AddresszenEndpointInputs[K], + AddresszenEndpointOutputs[K] + >; + +export type AddresszenEndpoints = { + autocompleteAddresses: AddresszenEndpoint<'autocompleteAddresses'>; + verifyAddress: AddresszenEndpoint<'verifyAddress'>; + keyAvailability: AddresszenEndpoint<'keyAvailability'>; + resolveAddressUsa: AddresszenEndpoint<'resolveAddressUsa'>; +}; + +const addresszenEndpointsNested = { + autocomplete: { + addresses: Autocomplete.addresses, + }, + verify: { + address: Verify.address, + }, + key: { + availability: Key.availability, + }, + resolve: { + addressUsa: Resolve.addressUsa, + }, +} as const; + +const addresszenWebhooksNested = {} as const; + +export const addresszenEndpointSchemas = { + 'autocomplete.addresses': { + input: AddresszenEndpointInputSchemas.autocompleteAddresses, + output: AddresszenEndpointOutputSchemas.autocompleteAddresses, + }, + 'verify.address': { + input: AddresszenEndpointInputSchemas.verifyAddress, + output: AddresszenEndpointOutputSchemas.verifyAddress, + }, + 'key.availability': { + input: AddresszenEndpointInputSchemas.keyAvailability, + output: AddresszenEndpointOutputSchemas.keyAvailability, + }, + 'resolve.addressUsa': { + input: AddresszenEndpointInputSchemas.resolveAddressUsa, + output: AddresszenEndpointOutputSchemas.resolveAddressUsa, + }, +} as const satisfies RequiredPluginEndpointSchemas< + typeof addresszenEndpointsNested +>; + +const defaultAuthType: AuthTypes = 'api_key' as const; + +const addresszenEndpointMeta = { + 'autocomplete.addresses': { + riskLevel: 'read', + description: + 'Get address autocomplete suggestions for a partial address query', + }, + 'verify.address': { + riskLevel: 'read', + description: + 'Verify and standardize a US address using USPS CASS validation', + }, + 'key.availability': { + riskLevel: 'read', + description: + 'Get public information on an API key, including whether it is currently usable', + }, + 'resolve.addressUsa': { + riskLevel: 'read', + description: + 'Resolve an address autocompletion by its address ID and return the full address in US format', + }, +} as const satisfies RequiredPluginEndpointMeta< + typeof addresszenEndpointsNested +>; + +export const addresszenAuthConfig = { + api_key: {}, +} as const satisfies PluginAuthConfig; + +export type BaseAddresszenPlugin = + CorsairPlugin< + 'addresszen', + typeof AddresszenSchema, + typeof addresszenEndpointsNested, + typeof addresszenWebhooksNested, + T, + typeof defaultAuthType + >; + +export type InternalAddresszenPlugin = + BaseAddresszenPlugin; + +export type ExternalAddresszenPlugin = + BaseAddresszenPlugin; + +export function addresszen( + incomingOptions: AddresszenPluginOptions & T = {} as AddresszenPluginOptions & + T, +): ExternalAddresszenPlugin { + const options = { + ...incomingOptions, + authType: incomingOptions.authType ?? defaultAuthType, + }; + return { + id: 'addresszen', + authConfig: addresszenAuthConfig, + schema: AddresszenSchema, + options: options, + hooks: options.hooks, + webhookHooks: undefined, + endpoints: addresszenEndpointsNested, + webhooks: addresszenWebhooksNested, + endpointMeta: addresszenEndpointMeta, + endpointSchemas: addresszenEndpointSchemas, + pluginWebhookMatcher: undefined, + errorHandlers: { + ...errorHandlers, + ...options.errorHandlers, + }, + keyBuilder: async (ctx: AddresszenKeyBuilderContext, source) => { + if (source === 'endpoint' && options.key) { + return options.key; + } + + if (source === 'endpoint' && ctx.authType === 'api_key') { + const res = await ctx.keys.get_api_key(); + if (!res) { + throw new AuthMissingError('addresszen', 'api_key'); + } + return res; + } + + throw new AuthMissingError('addresszen', 'api_key'); + }, + } satisfies InternalAddresszenPlugin; +} + +export type { + AddressSuggestion, + AddresszenEndpointInputs, + AddresszenEndpointOutputs, + AutocompleteAddressesInput, + AutocompleteAddressesResponse, + KeyAvailabilityInput, + KeyAvailabilityResponse, + ResolveAddressUsaInput, + ResolveAddressUsaResponse, + VerifyAddressInput, + VerifyAddressResponse, +} from './endpoints/types'; + +export { + AddresszenEndpointInputSchemas, + AddresszenEndpointOutputSchemas, + AutocompleteAddressesInputSchema, + AutocompleteAddressesResponseSchema, + KeyAvailabilityInputSchema, + KeyAvailabilityResponseSchema, + ResolveAddressUsaInputSchema, + ResolveAddressUsaResponseSchema, + VerifyAddressInputSchema, + VerifyAddressResponseSchema, +} from './endpoints/types'; diff --git a/packages/addresszen/jest.config.cjs b/packages/addresszen/jest.config.cjs new file mode 100644 index 000000000..296a927ca --- /dev/null +++ b/packages/addresszen/jest.config.cjs @@ -0,0 +1,54 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: [''], + testMatch: [ + '**/*.test.ts', + '**/tests/**/*.test.ts', + '**/plugins/**/*.test.ts', + '**/setup/**/*.test.ts', + ], + collectCoverageFrom: [ + '**/*.ts', + '!**/*.d.ts', + '!**/node_modules/**', + '!**/dist/**', + '!jest.config.ts', + '!tests/**', + ], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], + transform: { + '^.+\\.yaml$': '/../corsair/jest-yaml-transform.cjs', + '^.+\\.ts$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + verbatimModuleSyntax: false, + module: 'ESNext', + moduleResolution: 'Bundler', + }, + }, + ], + '.*\\.js$': [ + 'ts-jest', + { + useESM: true, + tsconfig: { + esModuleInterop: true, + allowSyntheticDefaultImports: true, + }, + }, + ], + }, + moduleNameMapper: { + '^corsair/http$': '/../corsair/http.ts', + '^(\\.\\.?/.*)\\.js$': '$1', + }, + transformIgnorePatterns: ['node_modules/(?!.*uuid.*)'], + extensionsToTreatAsEsm: ['.ts'], + testTimeout: 30000, + verbose: true, +}; diff --git a/packages/addresszen/package.json b/packages/addresszen/package.json new file mode 100644 index 000000000..40a42c2f8 --- /dev/null +++ b/packages/addresszen/package.json @@ -0,0 +1,45 @@ +{ + "name": "@corsair-dev/addresszen", + "version": "0.1.0", + "description": "Addresszen plugin for Corsair", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "dev-source": "./index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "rm -rf dist && tsc --build --force && tsup", + "typecheck": "tsc --noEmit", + "test": "jest" + }, + "peerDependencies": { + "corsair": ">=0.1.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@types/jest": "^29.5.14", + "corsair": "workspace:*", + "dotenv": "^17.2.3", + "jest": "^29.7.0", + "ts-jest": "^29.4.9", + "tsup": "^8.0.1", + "typescript": "catalog:", + "zod": "^4.1.13" + }, + "keywords": [ + "corsair", + "addresszen", + "plugin" + ], + "author": "", + "license": "Apache-2.0", + "files": [ + "dist" + ] +} diff --git a/packages/addresszen/schema/database.ts b/packages/addresszen/schema/database.ts new file mode 100644 index 000000000..92c6c137d --- /dev/null +++ b/packages/addresszen/schema/database.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export const AddresszenAutocompleteResult = z.object({ + query: z.string(), + // Suggestion objects vary between US and UK response formats. + hits: z.array(z.record(z.string(), z.unknown())), + code: z.number().optional(), + message: z.string().optional(), + updatedAt: z.coerce.date().nullable().optional(), +}); + +export const AddresszenVerifiedAddress = z.object({ + query: z.string(), + city: z.string().nullable().optional(), + state: z.string().nullable().optional(), + zipCode: z.string().nullable().optional(), + context: z.string().nullable().optional(), + // Stored verify payload mirrors the API result object, which varies by match type. + result: z.record(z.string(), z.unknown()), + code: z.number().optional(), + message: z.string().optional(), + updatedAt: z.coerce.date().nullable().optional(), +}); + +export const AddresszenKeyAvailability = z.object({ + available: z.boolean(), + context: z.string().nullable().optional(), + code: z.number().optional(), + message: z.string().optional(), + updatedAt: z.coerce.date().nullable().optional(), +}); + +export const AddresszenResolvedAddress = z.object({ + addressId: z.string(), + // Full US-format resolve payload; field set varies by dataset. + address: z.record(z.string(), z.unknown()), + code: z.number().optional(), + message: z.string().optional(), + updatedAt: z.coerce.date().nullable().optional(), +}); + +export type AddresszenAutocompleteResult = z.infer< + typeof AddresszenAutocompleteResult +>; +export type AddresszenVerifiedAddress = z.infer< + typeof AddresszenVerifiedAddress +>; +export type AddresszenKeyAvailability = z.infer< + typeof AddresszenKeyAvailability +>; +export type AddresszenResolvedAddress = z.infer< + typeof AddresszenResolvedAddress +>; diff --git a/packages/addresszen/schema/index.ts b/packages/addresszen/schema/index.ts new file mode 100644 index 000000000..c2c2e54cc --- /dev/null +++ b/packages/addresszen/schema/index.ts @@ -0,0 +1,16 @@ +import { + AddresszenAutocompleteResult, + AddresszenKeyAvailability, + AddresszenResolvedAddress, + AddresszenVerifiedAddress, +} from './database'; + +export const AddresszenSchema = { + version: '1.0.0', + entities: { + autocompleteResults: AddresszenAutocompleteResult, + verifiedAddresses: AddresszenVerifiedAddress, + keyAvailability: AddresszenKeyAvailability, + resolvedAddresses: AddresszenResolvedAddress, + }, +} as const; diff --git a/packages/addresszen/tsconfig.json b/packages/addresszen/tsconfig.json new file mode 100644 index 000000000..15e507a13 --- /dev/null +++ b/packages/addresszen/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "lib": ["esnext"], + "types": ["node", "jest"], + "module": "ESNext", + "moduleResolution": "Bundler", + "outDir": "./dist", + "rootDir": "./", + "composite": true, + "incremental": true, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "skipLibCheck": true + }, + "include": ["./**/*"], + "exclude": ["dist", "node_modules"], + "references": [] +} diff --git a/packages/addresszen/tsup.config.ts b/packages/addresszen/tsup.config.ts new file mode 100644 index 000000000..3ec221e23 --- /dev/null +++ b/packages/addresszen/tsup.config.ts @@ -0,0 +1,15 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + clean: false, + dts: false, + format: ['esm'], + target: 'esnext', + platform: 'node', + bundle: true, + splitting: true, + minify: true, + outDir: 'dist', + external: ['corsair', 'zod'], + entry: ['index.ts'], +}); diff --git a/packages/corsair/core/constants.ts b/packages/corsair/core/constants.ts index 0ea5826c4..da7e311ba 100644 --- a/packages/corsair/core/constants.ts +++ b/packages/corsair/core/constants.ts @@ -15,6 +15,7 @@ export type AllErrors = export const BaseProviders = [ 'abstract', 'activetrail', + 'addresszen', 'agentmail', 'agentql', 'ahrefs', @@ -114,6 +115,7 @@ export const BaseProviders = [ export const ProviderDisplayNames = { abstract: 'Abstract', activetrail: 'Active Trail', + addresszen: 'Addresszen', agentmail: 'AgentMail', agentql: 'AgentQL', ahrefs: 'Ahrefs', @@ -220,6 +222,7 @@ export function formatProviderDisplayName(plugin: string): string { export type AllProviders = | 'abstract' | 'activetrail' + | 'addresszen' | 'agentmail' | 'agentql' | 'ahrefs'