Skip to content
Merged
30 changes: 27 additions & 3 deletions components/openapi/api-playground/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useApiCredentials } from '@/providers/api-credentials-provider';
import type { OpenAPIOperation } from '../types';
import { RequestBuilder } from './request-builder';
import { executeRequest } from './request-executor';
import { coerceValueForSchema } from './schema-utils';

interface APIPlaygroundProps {
operation: OpenAPIOperation;
Expand Down Expand Up @@ -230,6 +231,8 @@ export function APIPlayground({
}

let finalFormData = { ...formData };
let bodyFieldErrors: string[] = [];

if (operation.requestBody) {
const bodySchema = operation.requestBody.content?.['application/json']?.schema;
if (bodySchema?.type === 'object' && bodySchema.properties) {
Expand Down Expand Up @@ -294,18 +297,39 @@ export function APIPlayground({
});
} catch (error) {
console.error('Failed to convert arguments:', error);
bodyObject[propName] = fieldValue;
bodyFieldErrors.push(
error instanceof Error ? error.message : `Invalid value provided for ${propName}`,
);
}
} else if (propName === 'sender') {
// Sender stays as string
bodyObject[propName] = fieldValue;
} else {
// Other fields - no conversion for now
bodyObject[propName] = fieldValue;
try {
bodyObject[propName] = coerceValueForSchema(fieldValue, propSchema, {
strict: true,
fieldName: propName,
});
} catch (error) {
bodyFieldErrors.push(
error instanceof Error ? error.message : `Invalid value provided for ${propName}`,
);
}
}
}
}

if (bodyFieldErrors.length > 0) {
if (!openSections.includes('body')) {
setOpenSections((prev) => [...prev, 'body']);
}
setResponse({
status: 0,
error: bodyFieldErrors[0],
});
return;
}

finalFormData = {
...finalFormData,
body: JSON.stringify(bodyObject, null, 2),
Expand Down
76 changes: 66 additions & 10 deletions components/openapi/api-playground/request-builder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { cn } from '@/lib/utils';
import type { OpenAPIOperation, OpenAPIParameter } from '../types';
import { ClarityConverter, type ClarityTypeHint } from './clarity-converter';
import { coerceValueForSchema, resolveEffectiveSchema } from './schema-utils';

interface RequestBuilderProps {
operation: OpenAPIOperation;
Expand Down Expand Up @@ -135,12 +136,14 @@ export const RequestBuilder = forwardRef<HTMLFormElement, RequestBuilderProps>(
const bodySchema = operation.requestBody.content?.['application/json']?.schema;
if (bodySchema?.type === 'object' && bodySchema.properties) {
const bodyObject: Record<string, any> = {};
const parseErrors: Record<string, string> = {};

for (const [propName, propSchema] of Object.entries(bodySchema.properties) as [
string,
any,
][]) {
const fieldValue = formData[`body.${propName}`];
const fieldName = `body.${propName}`;
if (fieldValue !== undefined && fieldValue !== '') {
if (clarityConversion) {
if (propName === 'arguments' && propSchema.type === 'array') {
Expand All @@ -152,7 +155,10 @@ export const RequestBuilder = forwardRef<HTMLFormElement, RequestBuilderProps>(
});
} catch (error) {
console.error('Failed to convert arguments:', error);
bodyObject[propName] = fieldValue;
parseErrors[fieldName] =
error instanceof Error
? error.message
: `Invalid value provided for ${propName}`;
}
} else {
if (propName === 'sender') {
Expand All @@ -167,19 +173,54 @@ export const RequestBuilder = forwardRef<HTMLFormElement, RequestBuilderProps>(
);
bodyObject[propName] = cvToHex(clarityValue);
} catch (error) {
bodyObject[propName] = fieldValue;
try {
bodyObject[propName] = coerceValueForSchema(fieldValue, propSchema, {
strict: true,
fieldName: propName,
});
} catch (coerceError) {
parseErrors[fieldName] =
coerceError instanceof Error
? coerceError.message
: `Invalid value provided for ${propName}`;
}
}
} else {
bodyObject[propName] = fieldValue;
try {
bodyObject[propName] = coerceValueForSchema(fieldValue, propSchema, {
strict: true,
fieldName: propName,
});
} catch (error) {
parseErrors[fieldName] =
error instanceof Error
? error.message
: `Invalid value provided for ${propName}`;
}
}
}
}
} else {
bodyObject[propName] = fieldValue;
try {
bodyObject[propName] = coerceValueForSchema(fieldValue, propSchema, {
strict: true,
fieldName: propName,
});
} catch (error) {
parseErrors[fieldName] =
error instanceof Error
? error.message
: `Invalid value provided for ${propName}`;
}
}
}
}

if (Object.keys(parseErrors).length > 0) {
setErrors((prev) => ({ ...prev, ...parseErrors }));
return;
}

finalFormData = {
...finalFormData,
body: JSON.stringify(bodyObject, null, 2),
Expand Down Expand Up @@ -422,9 +463,26 @@ export const RequestBuilder = forwardRef<HTMLFormElement, RequestBuilderProps>(
const fieldName = `body.${propName}`;
const isRequired = bodySchema.required?.includes(propName);
const hasError = !!errors[fieldName];
const resolvedPropSchema = resolveEffectiveSchema(propSchema) || propSchema;
const schemaType = resolvedPropSchema?.type || propSchema.type;
const clarityType = clarityConversion
? detectClarityType(propName, propSchema, formData[fieldName] || '')
: null;
const exampleValue =
typeof propSchema.example === 'string'
? propSchema.example
: propSchema.example
? JSON.stringify(propSchema.example, null, 2)
: undefined;
const placeholder =
exampleValue ||
propSchema.description ||
(schemaType === 'object'
? 'Enter JSON object'
: schemaType === 'array'
? 'Enter array values as JSON array'
: undefined);
const shouldUseTextarea = schemaType === 'array' || schemaType === 'object';

return (
<div key={propName} className="space-y-2">
Expand All @@ -451,19 +509,17 @@ export const RequestBuilder = forwardRef<HTMLFormElement, RequestBuilderProps>(
)}
</div>

{propSchema.type === 'array' ? (
{shouldUseTextarea ? (
<Textarea
id={fieldName}
value={formData[fieldName] || ''}
onChange={(e) => handleInputChange(fieldName, e.target.value)}
placeholder={
propSchema.example?.toString() ||
propSchema.description ||
(propName === 'arguments'
propName === 'arguments' && schemaType === 'array'
? 'e.g. [SP123...] or [SP123..., 100] or [1, [2, 3, 4]]'
: 'Enter array values as JSON array')
: placeholder
}
rows={3}
rows={schemaType === 'array' ? 3 : 5}
className={cn(
'font-mono text-sm bg-white dark:bg-neutral-950 border-border',
hasError && 'border-red-500',
Expand Down
9 changes: 7 additions & 2 deletions components/openapi/api-playground/request-executor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,6 @@ export async function executeRequest(
// Build headers
const headers: Record<string, string> = {
Accept: 'application/json',
'Content-Type': 'application/json',
};

const headerParameters = operation.parameters?.filter((p) => p.in === 'header') || [];
Expand All @@ -90,7 +89,9 @@ export async function executeRequest(
);

let requestBody: any;
if (formData.body && (operation.requestBody || methodSupportsBody)) {
const shouldAttachBody = formData.body && (operation.requestBody || methodSupportsBody);

if (shouldAttachBody) {
try {
// Parse and re-stringify to validate JSON
const parsedBody = JSON.parse(formData.body);
Expand All @@ -104,6 +105,10 @@ export async function executeRequest(
console.warn(`Request body is required for ${operation.method} ${operation.path}`);
}

if (requestBody !== undefined) {
headers['Content-Type'] = 'application/json';
}

const startTime = performance.now();

try {
Expand Down
120 changes: 120 additions & 0 deletions components/openapi/api-playground/schema-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
type JSONSchema = {
type?: string | string[];
anyOf?: JSONSchema[];
oneOf?: JSONSchema[];
allOf?: JSONSchema[];
[key: string]: any;
};

interface CoerceOptions {
strict?: boolean;
fieldName?: string;
}

const COMPOSITE_KEYS: Array<'anyOf' | 'oneOf' | 'allOf'> = ['anyOf', 'oneOf', 'allOf'];

const normalizeType = (schemaType?: string | string[]) => {
if (!schemaType) return undefined;
if (typeof schemaType === 'string') {
return schemaType === 'null' ? undefined : schemaType;
}
if (Array.isArray(schemaType)) {
return schemaType.find((type) => type !== 'null');
}
return undefined;
};

export const resolveEffectiveSchema = (schema?: JSONSchema): JSONSchema | undefined => {
if (!schema) return undefined;

const normalizedType = normalizeType(schema.type);
if (normalizedType) {
return { ...schema, type: normalizedType };
}

for (const key of COMPOSITE_KEYS) {
const candidates = schema[key];
if (!Array.isArray(candidates)) continue;

for (const candidate of candidates) {
const resolved = resolveEffectiveSchema(candidate);
if (resolved?.type && resolved.type !== 'null') {
return resolved;
}
}

if (candidates.length > 0) {
return candidates[0];
}
}

return schema;
};

const formatErrorMessage = (fieldName: string | undefined, message: string) =>
fieldName ? `${fieldName} ${message}` : message;

export const coerceValueForSchema = (
rawValue: string,
schema?: JSONSchema,
options?: CoerceOptions,
) => {
if (rawValue === undefined || rawValue === null) return rawValue;
if (typeof rawValue !== 'string') return rawValue;

const trimmed = rawValue.trim();
if (!trimmed) return rawValue;
if (trimmed === 'null') return null;

const resolvedSchema = resolveEffectiveSchema(schema);
const targetType = resolvedSchema?.type;

if (targetType === 'object' || targetType === 'array') {
try {
return JSON.parse(trimmed);
} catch {
if (options?.strict) {
throw new Error(
formatErrorMessage(
options.fieldName,
`must be valid JSON ${targetType === 'array' ? 'array' : 'object'}`,
),
);
}
console.warn('Failed to parse JSON body field value. Sending raw string instead.');
return rawValue;
}
}

if (targetType === 'integer' || targetType === 'number') {
const parsed = Number(trimmed);
if (Number.isNaN(parsed)) {
if (options?.strict) {
throw new Error(
formatErrorMessage(
options.fieldName,
`must be a valid ${targetType === 'integer' ? 'integer' : 'number'}`,
),
);
}
return rawValue;
}

if (options?.strict && targetType === 'integer' && !Number.isInteger(parsed)) {
throw new Error(formatErrorMessage(options.fieldName, 'must be an integer'));
}

return parsed;
}

if (targetType === 'boolean') {
if (trimmed.toLowerCase() === 'true') return true;
if (trimmed.toLowerCase() === 'false') return false;
if (options?.strict) {
throw new Error(formatErrorMessage(options.fieldName, 'must be a boolean (true or false)'));
}
return rawValue;
}

return rawValue;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
title: Get consumer secret
sidebarTitle: Get consumer secret
description: Retrieves current event payload consumer secret
full: true
---

<APIPage
document="./openapi/chainhook-api.json"
operations={[{ path: '/me/secret', method: 'get' }]}
hasHead={false}
/>
14 changes: 14 additions & 0 deletions content/docs/es/apis/chainhooks-api/reference/chainhooks/meta.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions content/docs/es/apis/chainhooks-api/reference/info/meta.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading