Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 42 additions & 8 deletions src/config/sdkConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,10 @@ export type GuildPassClientConfig = {

export function validateConfig(config: GuildPassClientConfig): void {
if (!config.apiUrl) {
throw new GuildPassError('apiUrl is required', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('apiUrl is required', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'apiUrl',
reason: 'required',
});
}

try {
Expand All @@ -90,6 +93,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
`Invalid apiUrl: "${config.apiUrl}"`,
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'apiUrl', reason: 'format' },
);
}

Expand All @@ -100,17 +105,21 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
'timeoutMs must be a positive number',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'timeoutMs', reason: 'invalid_type' },
);
}

// INSERT CACHE VALIDATION
// INSERT CACHE VALIDATION
if (
config.cacheTtl !== undefined &&
(typeof config.cacheTtl !== 'number' || config.cacheTtl < 0 || !Number.isFinite(config.cacheTtl))
) {
throw new GuildPassError(
'cacheTtl must be a non-negative finite number (milliseconds)',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'cacheTtl', reason: 'invalid_range' },
);
}

Expand All @@ -122,6 +131,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
`cache adapter must implement ${method}(): function`,
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'cache', reason: 'missing_method' },
);
}
}
Expand All @@ -136,6 +147,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
'sendClientMetadata must be a boolean',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'sendClientMetadata', reason: 'invalid_type' },
);
}

Expand All @@ -146,6 +159,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
'clientName must be a string',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'clientName', reason: 'invalid_type' },
);
}

Expand All @@ -156,6 +171,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
'clientVersion must be a string',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'clientVersion', reason: 'invalid_type' },
);
}
// END METADATA VALIDATION
Expand All @@ -165,26 +182,41 @@ export function validateConfig(config: GuildPassClientConfig): void {
const r = config.retry;

if (r.maxRetries !== undefined && (typeof r.maxRetries !== 'number' || !Number.isFinite(r.maxRetries) || r.maxRetries < 0)) {
throw new GuildPassError('retry.maxRetries must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('retry.maxRetries must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'retry.maxRetries',
reason: 'invalid_range',
});
}

if (r.baseDelayMs !== undefined && (typeof r.baseDelayMs !== 'number' || !Number.isFinite(r.baseDelayMs) || r.baseDelayMs < 0)) {
throw new GuildPassError('retry.baseDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('retry.baseDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'retry.baseDelayMs',
reason: 'invalid_range',
});
}

if (r.maxDelayMs !== undefined && (typeof r.maxDelayMs !== 'number' || !Number.isFinite(r.maxDelayMs) || r.maxDelayMs < 0)) {
throw new GuildPassError('retry.maxDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('retry.maxDelayMs must be a non-negative finite number', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'retry.maxDelayMs',
reason: 'invalid_range',
});
}

if (r.baseDelayMs !== undefined && r.maxDelayMs !== undefined && r.maxDelayMs < r.baseDelayMs) {
throw new GuildPassError('retry.maxDelayMs cannot be less than baseDelayMs', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('retry.maxDelayMs cannot be less than baseDelayMs', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'retry.maxDelayMs',
reason: 'invalid_range',
});
}

if (r.retryableStatuses !== undefined && (!Array.isArray(r.retryableStatuses) || r.retryableStatuses.length === 0 || r.retryableStatuses.some((s) => typeof s !== 'number' || !Number.isFinite(s)))) {
throw new GuildPassError('retryableStatuses must be a non-empty array of valid HTTP status numbers', GuildPassErrorCode.INVALID_CONFIG);
throw new GuildPassError('retryableStatuses must be a non-empty array of valid HTTP status numbers', GuildPassErrorCode.INVALID_CONFIG, undefined, {
field: 'retry.retryableStatuses',
reason: 'invalid_format',
});
}
}
// END RETRY VALIDATION
// END RETRY VALIDATION

validateChainsConfig(config.chains);

Expand All @@ -193,6 +225,8 @@ export function validateConfig(config: GuildPassClientConfig): void {
throw new GuildPassError(
'A fetch-compatible transport is required. Provide config.fetch or use a runtime with globalThis.fetch.',
GuildPassErrorCode.INVALID_CONFIG,
undefined,
{ field: 'fetch', reason: 'missing' },
);
}
}
Expand Down
48 changes: 44 additions & 4 deletions src/utils/validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,19 @@ import { GuildPassErrorCode } from '../errors/errorCodes';
// GuildPass SDK: Pull in package or module bindings.
import { isChecksumAddress } from './address';

/**
* Structured metadata attached to validation errors so consumers can
* identify which field failed and why without parsing the message string.
*/
export interface ValidationErrorDetails {
/** The name of the field that failed validation (e.g. "address", "guildId"). */
field: string;
/** Machine-readable reason code (e.g. "required", "format", "checksum"). */
reason: string;
/** Optional type hint for the rejected value (e.g. "address", "guildId"). */
valueType?: string;
}

/**
* Validates an Ethereum address.
*
Expand All @@ -17,7 +30,10 @@ export const validateAddress = (address: string, options: { strict?: boolean } =
// GuildPass SDK: Evaluate branch condition logic.
if (!address) {
// GuildPass SDK: Raise exceptional condition and throw error.
throw new GuildPassError('Address is required', GuildPassErrorCode.INVALID_INPUT);
throw new GuildPassError('Address is required', GuildPassErrorCode.INVALID_INPUT, undefined, {
field: 'address',
reason: 'required',
} as ValidationErrorDetails);
// GuildPass SDK: End of logic containment structure block.
}

Expand All @@ -29,6 +45,12 @@ export const validateAddress = (address: string, options: { strict?: boolean } =
throw new GuildPassError(
`Invalid Ethereum address: ${address}`,
GuildPassErrorCode.INVALID_ADDRESS,
undefined,
{
field: 'address',
reason: 'format',
valueType: 'address',
} as ValidationErrorDetails,
);
// GuildPass SDK: End of logic containment structure block.
}
Expand All @@ -38,6 +60,12 @@ export const validateAddress = (address: string, options: { strict?: boolean } =
throw new GuildPassError(
`Address fails EIP-55 checksum: ${address}`,
GuildPassErrorCode.INVALID_ADDRESS,
undefined,
{
field: 'address',
reason: 'checksum',
valueType: 'address',
} as ValidationErrorDetails,
);
}
// GuildPass SDK: End of logic containment structure block.
Expand All @@ -54,7 +82,11 @@ export const validateGuildId = (guildId: string): void => {
// GuildPass SDK: Verify constraint requirements before proceeding.
if (!guildId || typeof guildId !== 'string' || guildId.trim().length === 0) {
// GuildPass SDK: Raise exceptional condition and throw error.
throw new GuildPassError('Invalid Guild ID', GuildPassErrorCode.INVALID_INPUT);
throw new GuildPassError('Invalid Guild ID', GuildPassErrorCode.INVALID_INPUT, undefined, {
field: 'guildId',
reason: 'required',
valueType: 'guildId',
} as ValidationErrorDetails);
// GuildPass SDK: End of logic containment structure block.
}
// GuildPass SDK: End of logic containment structure block.
Expand All @@ -71,7 +103,11 @@ export const validateResourceId = (resourceId: string): void => {
// GuildPass SDK: Evaluate branch condition logic.
if (!resourceId || typeof resourceId !== 'string' || resourceId.trim().length === 0) {
// GuildPass SDK: Propagate error state with specific code description.
throw new GuildPassError('Invalid Resource ID', GuildPassErrorCode.INVALID_INPUT);
throw new GuildPassError('Invalid Resource ID', GuildPassErrorCode.INVALID_INPUT, undefined, {
field: 'resourceId',
reason: 'required',
valueType: 'resourceId',
} as ValidationErrorDetails);
// GuildPass SDK: End of logic containment structure block.
}
// GuildPass SDK: End of logic containment structure block.
Expand All @@ -88,7 +124,11 @@ export const validateRoleId = (roleId: string): void => {
// GuildPass SDK: Conditional check guard path.
if (!roleId || typeof roleId !== 'string' || roleId.trim().length === 0) {
// GuildPass SDK: Raise exceptional condition and throw error.
throw new GuildPassError('Invalid Role ID', GuildPassErrorCode.INVALID_INPUT);
throw new GuildPassError('Invalid Role ID', GuildPassErrorCode.INVALID_INPUT, undefined, {
field: 'roleId',
reason: 'required',
valueType: 'roleId',
} as ValidationErrorDetails);
// GuildPass SDK: End of logic containment structure block.
}
// GuildPass SDK: End of logic containment structure block.
Expand Down
90 changes: 90 additions & 0 deletions tests/config-validation-metadata.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
// GuildPass SDK: Import external module dependencies.
import { describe, it, expect } from 'vitest';
// GuildPass SDK: Pull in package or module bindings.
import { GuildPassError } from '../src/errors/GuildPassError';
// GuildPass SDK: Import external module dependencies.
import { GuildPassErrorCode } from '../src/errors/errorCodes';
// GuildPass SDK: Pull in package or module bindings.
import { validateConfig } from '../src/config/sdkConfig';

// GuildPass SDK: Validate unit assertion test case.
describe('validateConfig structured error metadata', () => {
// GuildPass SDK: Test suite container block.
it('throws with field=apiUrl when missing', () => {
try {
validateConfig({ apiUrl: '' });
} catch (e) {
const err = e as GuildPassError;
expect(err.details).toBeDefined();
expect(err.details.field).toBe('apiUrl');
expect(err.details.reason).toBe('required');
}
});

// GuildPass SDK: Test suite container block.
it('throws with field=apiUrl when URL format is invalid', () => {
try {
validateConfig({ apiUrl: 'not-a-url' });
} catch (e) {
const err = e as GuildPassError;
expect(err.details.field).toBe('apiUrl');
expect(err.details.reason).toBe('format');
}
});

// GuildPass SDK: Test suite container block.
it('throws with field=timeoutMs for invalid value', () => {
try {
validateConfig({ apiUrl: 'https://example.com', timeoutMs: -1 });
} catch (e) {
const err = e as GuildPassError;
expect(err.details.field).toBe('timeoutMs');
expect(err.details.reason).toBe('invalid_type');
}
});

// GuildPass SDK: Test suite container block.
it('throws with field=cacheTtl for negative value', () => {
try {
validateConfig({ apiUrl: 'https://example.com', cacheTtl: -5 });
} catch (e) {
const err = e as GuildPassError;
expect(err.details.field).toBe('cacheTtl');
expect(err.details.reason).toBe('invalid_range');
}
});

// GuildPass SDK: Test suite container block.
it('throws with field=sendClientMetadata for non-boolean', () => {
try {
validateConfig({ apiUrl: 'https://example.com', sendClientMetadata: 'yes' as any });
} catch (e) {
const err = e as GuildPassError;
expect(err.details.field).toBe('sendClientMetadata');
expect(err.details.reason).toBe('invalid_type');
}
});

// GuildPass SDK: Test suite container block.
it('throws with field=retry.maxRetries for negative', () => {
try {
validateConfig({ apiUrl: 'https://example.com', retry: { maxRetries: -1 } });
} catch (e) {
const err = e as GuildPassError;
expect(err.details.field).toBe('retry.maxRetries');
expect(err.details.reason).toBe('invalid_range');
}
});

// GuildPass SDK: Test suite container block.
it('does not expose apiKey in error details', () => {
try {
validateConfig({ apiUrl: '' });
} catch (e) {
const err = e as GuildPassError;
expect(err.details).not.toHaveProperty('value');
expect(err.details).not.toHaveProperty('apiKey');
}
});
// GuildPass SDK: End of logic containment structure block.
});
Loading