diff --git a/.gitignore b/.gitignore index 9b1ee42..f96571c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,8 @@ +docs/api-docs.json + + + + # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore # Logs diff --git a/docs/favicon.ico b/docs/favicon.ico new file mode 100644 index 0000000..bc882e6 Binary files /dev/null and b/docs/favicon.ico differ diff --git a/docs/generate.ts b/docs/generate.ts index 1c39f66..f603bbe 100644 --- a/docs/generate.ts +++ b/docs/generate.ts @@ -1,127 +1,75 @@ -import * as ts from 'typescript'; -import * as fs from 'fs'; import * as path from 'path'; -import { CustomType, extractZodSchema } from './generateTypesDocs'; -import { getMethodDocs, type MethodDoc } from './generateMethodsDocs'; - -interface Documentation { - classMethods: MethodDoc[]; - exportedTypes: CustomType[]; -} +import { Documentation, MethodDocumentation, TypeDocumentation } from './types'; +import { generateTypeDocs } from './generateTypesDocs'; +import { generateMethodDocs } from './generateMethodsDocs'; +import { + getAllFiles, + createTSProgram, + writeDocs +} from './shared-utils'; function generateDocs(sourceFiles: string[]): Documentation { - const options: ts.CompilerOptions = { - target: ts.ScriptTarget.ESNext, - module: ts.ModuleKind.ESNext, - allowJs: true, - checkJs: true, - noEmit: true, - types: ['node'], - skipLibCheck: true, - }; - - const program = ts.createProgram(sourceFiles, options); - const methods: MethodDoc[] = []; - - // Sort source files by their base names - const sortedFiles = [...sourceFiles].sort((a, b) => - path.basename(a).localeCompare(path.basename(b)) - ); - - // Use OrderedMap to maintain file ordering - const typesByFile = new Map(); + const program = createTSProgram(sourceFiles); + const methods: MethodDocumentation[] = []; + const typesByFile = new Map(); - // First pass: collect types maintaining file order - for (const sourceFile of sortedFiles) { - const fileName = sourceFile; - - if (!fileName.endsWith('.ts') || fileName.includes('node_modules')) { - continue; - } - - const content = fs.readFileSync(fileName, 'utf8'); - - if (content.includes('z.object') || content.includes('z.enum')) { - const extracted = extractZodSchema(content, path.basename(fileName)); - if (extracted.length > 0) { - typesByFile.set(fileName, extracted); - } - } - } - - // Second pass: collect methods + // First pass: collect types for (const sourceFile of program.getSourceFiles()) { const fileName = sourceFile.fileName; - if (!fileName.endsWith('.ts') || fileName.includes('node_modules')) { + // Skip node_modules and declaration files + if (fileName.includes('node_modules') || fileName.endsWith('.d.ts')) { continue; } - function visit(node: ts.Node) { - if (ts.isClassDeclaration(node)) { - - node.members.forEach(member => { - if (ts.isMethodDeclaration(member)) { - try { - const doc = getMethodDocs(member, sourceFile); - if (doc) { - methods.push(doc); - } - } catch (error) { - console.error('Error processing method:', error); - } - } - }); + // Look for Zod schemas + if (sourceFile.getText().includes('z.object') || + sourceFile.getText().includes('z.enum')) { + const types = generateTypeDocs(sourceFile, program); + if (types.length > 0) { + typesByFile.set(fileName, types); } - - ts.forEachChild(node, visit); } - visit(sourceFile); + // Also collect methods from this file + const fileMethods = generateMethodDocs(sourceFile, program); + if (fileMethods.length > 0) { + methods.push(...fileMethods); + } } - // Combine types in file order - const allTypes: CustomType[] = []; - for (const [file, types] of typesByFile.entries()) { - allTypes.push(...types); - } + // Combine all types + const allTypes = Array.from(typesByFile.values()).flat(); return { - classMethods: methods, - exportedTypes: allTypes + methods: methods.sort((a, b) => a.name.localeCompare(b.name)), + types: allTypes.sort((a, b) => a.name.localeCompare(b.name)) }; } -// Ensure the docs directory exists -const docsDir = path.join(process.cwd(), 'docs'); -if (!fs.existsSync(docsDir)) { - fs.mkdirSync(docsDir); -} +// Main execution +const SOURCE_DIR = './src'; +const DOCS_DIR = path.join(process.cwd(), 'docs'); -// Generate documentation -const sourceDir = './src'; -const sourceFiles = [ - ...fs.readdirSync(sourceDir) - .filter(file => file.endsWith('.ts')) - .map(file => path.join(sourceDir, file)), - ...getAllFiles(path.join(sourceDir, 'schemas')) - .filter(file => file.endsWith('.ts')) -].sort((a, b) => path.basename(a).localeCompare(path.basename(b))); // Sort all source files +// Get source files +const sourceFiles = getAllFiles({ + sourceDir: SOURCE_DIR, + includeExtensions: ['.ts'], + excludePatterns: [ + /\.test\.ts$/, + /\.spec\.ts$/, + /\.d\.ts$/, + /\/dist\//, + /\/build\//, + /\/node_modules\// + ] +}); +// Generate and write documentation const docs = generateDocs(sourceFiles); - -fs.writeFileSync( - path.join(docsDir, 'api-docs.json'), - JSON.stringify(docs, null, 2) +writeDocs( + path.join(DOCS_DIR, 'api-docs.json'), + docs ); -function getAllFiles(dir: string): string[] { - if (!fs.existsSync(dir)) { - return []; - } - const files = fs.readdirSync(dir); - return files.flatMap(file => { - const fullPath = path.join(dir, file); - return fs.statSync(fullPath).isDirectory() ? getAllFiles(fullPath) : fullPath; - }); -} \ No newline at end of file +console.log(`Documentation generated from ${sourceFiles.length} files at docs/api-docs.json`); \ No newline at end of file diff --git a/docs/generateHtml.ts b/docs/generateHtml.ts index d7d525f..7efbd35 100644 --- a/docs/generateHtml.ts +++ b/docs/generateHtml.ts @@ -1,213 +1,226 @@ import fs from 'fs'; import path from 'path'; -function escapeHtml(text) { - return text - .replace(//g, '>'); - } +function generateHtml() { + const config = { + repoAddress: 'https://github.com/raphjaph/ordapi', + version: 'v0.0.3' + }; + const scriptsContent = fs.readFileSync('docs/scripts.js', 'utf-8'); + const apiDocs = JSON.parse(fs.readFileSync('docs/api-docs.json', 'utf-8')); + const cssContent = fs.readFileSync('docs/styles.css', 'utf-8'); + const typeNames = new Set(apiDocs.types.map(type => type.name)); - function generateMethodCard(method) { - const parameters = method.parameters?.length > 0 - ? ` -
-
-

Parameters

-
- ${method.parameters.map(param => ` -
-
- ${param.name} - ${escapeHtml(param.type)} -
- ${param.description ? `

${param.description}

` : ''} -
- `).join('')} -
-
-
- ` - : ''; - - return ` -
-
-
-

- ${method.name} -

- → ${escapeHtml(method.returnType)} -
- ${method.description ? `

${method.description}

` : ''} -
- - ${method.httpMethod} - - ${method.endpoint} -
-
- ${parameters} -
- `; + function createTypeLink(type) { + const escaped = type.replace(//g, '>'); + let result = escaped; + const typeMatches = [...type.matchAll(/[A-Z][a-zA-Z0-9]*/g)]; + + for (let i = typeMatches.length - 1; i >= 0; i--) { + const match = typeMatches[i]; + const typeName = match[0]; + if (typeNames.has(typeName)) { + const startPos = result.indexOf(typeName, match.index); + if (startPos !== -1) { + result = + result.slice(0, startPos) + + `${typeName}` + + result.slice(startPos + typeName.length); + } + } + } + return result; } -function generateTypeCard(type) { - const propertiesSection = type.properties ? ` -
-

Properties

-
- ${type.properties.map(prop => ` -
-
- ${prop.name} - ${prop.type} -
- ${prop.description ? `

${prop.description}

` : ''} -
- `).join('')} -
-
- ` : ''; - - const valuesSection = type.values ? ` -
-

Values

-
- - ${type.values.join(' | ')} - -
-
- ` : ''; - - return ` -
-
-

- ${type.name} -

- ${type.description ? `

${type.description}

` : - `

Type defined in ${type.sourceFile}

`} -
- ${propertiesSection} - ${valuesSection} -
- `; -} - -function generateHtml() { - const apiDocs = JSON.parse(fs.readFileSync('docs/api-docs.json', 'utf-8')); - const htmlTemplate = ` OrdAPI Documentation + + - -
-
-
-
-
-

OrdAPI v0.0.3

-

Simple TypeScript client for ord API.

-
-
- - - -
+ +
+
-
-
- -
-
- ${apiDocs.classMethods.map(generateMethodCard).join('')} -
- -
- - +
+
+ ${apiDocs.types.map(type => ` +
+
+

${type.name}

+

+ ${type.description || `Type defined in ${type.sourceFile}`} +

+
+ ${type.properties ? ` + +
+ ${type.properties.map(prop => ` +
+
+ ${prop.name} + ${createTypeLink(prop.type)} +
+ ${prop.description ? `

${prop.description}

` : ''} +
+ `).join('')} +
+ ` : ''} + ${type.values ? ` + +
+
+ + ${type.values.join(' | ')} + +
+
+ ` : ''} +
+ `).join('')} +
+
+ +
+
+ `; - // Create dist directory if it doesn't exist const distDir = path.join(process.cwd(), 'docs/dist'); if (!fs.existsSync(distDir)) { fs.mkdirSync(distDir, { recursive: true }); } + fs.copyFileSync( + path.join(process.cwd(), 'docs/favicon.ico'), + path.join(distDir, 'favicon.ico') + ); - // Write the HTML file fs.writeFileSync( path.join(distDir, 'index.html'), htmlTemplate ); + fs.writeFileSync( + path.join(distDir, 'styles.css'), + cssContent + ); + + fs.writeFileSync(path.join(distDir, 'scripts.js'), scriptsContent); + console.log('Documentation HTML generated successfully!'); } -generateHtml(); +generateHtml(); \ No newline at end of file diff --git a/docs/generateMethodsDocs.ts b/docs/generateMethodsDocs.ts index 885b547..2132bd1 100644 --- a/docs/generateMethodsDocs.ts +++ b/docs/generateMethodsDocs.ts @@ -1,149 +1,120 @@ import * as ts from 'typescript'; +import { MethodDocumentation } from './types'; +import { visitNodes } from './shared-utils'; +import { extractJSDoc } from './utils/jsdoc-extractor'; +import api from '../src/api'; /** - * Represents a method parameter documentation + * Generates documentation for all methods in a source file */ -interface ParameterDoc { - name: string; - type: string; - description: string; +export function generateMethodDocs( + sourceFile: ts.SourceFile, + program: ts.Program +): MethodDocumentation[] { + const methods: MethodDocumentation[] = []; + const typeChecker = program.getTypeChecker(); + + // Extract all method documentation from the file + const methodDocumentation = extractJSDoc( + sourceFile, + node => ts.isMethodDeclaration(node) + ); + + // Process each documented method + methodDocumentation.forEach((docs, identifier) => { + const methodName = identifier.split(':')[1]; + + if (!isValidMethod(methodName)) { + return; + } + + // Find the actual method node + visitNodes(sourceFile, (node) => { + if (ts.isMethodDeclaration(node) && + (node.name as ts.Identifier).text === methodName) { + + const { parameters, endpoint, httpMethod } = extractMethodInfo(node, typeChecker); + + methods.push({ + name: methodName, + description: docs.description, + parameters: parameters.map(param => ({ + ...param, + description: docs.params[param.name] || '' + })), + endpoint, + httpMethod, + returnType: node.type?.getText(sourceFile) || 'Promise', + recursive: endpoint.startsWith('/r/'), + sourceFile: sourceFile.fileName + }); + } + }); + }); + + return methods; } /** - * Represents a method documentation + * Checks if a method should be documented */ -export interface MethodDoc { - name: string; - parameters: ParameterDoc[]; - description: string; +function isValidMethod(methodName: string): boolean { + return Boolean( + methodName && + !methodName.startsWith('_') && + !['fetch', 'fetchPost'].includes(methodName) + ); +} + +interface MethodInfo { + parameters: Array<{name: string; type: string}>; endpoint: string; httpMethod: 'GET' | 'POST'; - returnType: string; - responseSchema: string; } -/** - * Map of method names to their corresponding API endpoints - */ -const API_MAP = { - getAddressInfo: '/address/{address}', - getBlock: '/block/{heightOrHash}', - getBlockCount: '/blockcount', - getBlockHashByHeight: '/blockhash/{height}', - getLatestBlockHash: '/blockhash', - getLatestBlockHeight: '/blockheight', - getLatestBlocks: '/blocks', - getLatestBlockTime: '/blocktime', - getInscription: '/inscription/{id}', - getInscriptionChild: '/inscription/{id}/{child}', - getLatestInscriptions: '/inscriptions', - getInscriptionsByPage: '/inscriptions/{page}', - getInscriptionsByBlock: '/inscriptions/block/{height}', - getInscriptionsByIds: '/inscriptions', - getOutput: '/output/{outpoint}', - getOutputs: '/outputs', - getOutputsByAddress: '/outputs/{address}?type={type}', - getRune: '/rune/{name}', - getLatestRunes: '/runes', - getRunesByPage: '/runes/{page}', - getSat: '/sat/{number}', - getTransaction: '/tx/{txId}', - getServerStatus: '/status' -}; +function extractMethodInfo( + methodNode: ts.MethodDeclaration, + typeChecker: ts.TypeChecker +): MethodInfo { + const parameters = methodNode.parameters.map(param => ({ + name: (param.name as ts.Identifier).escapedText.toString(), + type: param.type + ? typeChecker.typeToString(typeChecker.getTypeFromTypeNode(param.type)) + : 'any' + })); + + const httpMethod = methodNode.getText().includes('this.fetchPost') ? 'POST' : 'GET'; + const methodName = (methodNode.name as ts.Identifier).escapedText.toString(); + const paramNames = parameters.map(p => p.name); + const endpoint = buildEndpoint(methodName, paramNames); + + return { + parameters, + endpoint, + httpMethod + }; +} -interface ParsedJSDoc { - description: string; - params: Record; - returns?: string; +function buildEndpoint(methodName: string, paramNames: string[]): string { + if (api[methodName] && typeof api[methodName] === 'string') { + return api[methodName]; } - - function parseJSDocComment(comment: string): ParsedJSDoc { - const lines = comment - .replace(/\/\*\*|\*\/|\*/g, '') - .split('\n') - .map(line => line.trim()) - .filter(Boolean); - - const result: ParsedJSDoc = { - description: '', - params: {}, - }; - - let currentSection = 'description'; - - for (const line of lines) { - if (line.startsWith('@param')) { - // Format: @param {type} name - description - const paramMatch = line.match(/@param\s+\{([^}]+)\}\s+(\w+)\s*-?\s*(.*)/); - if (paramMatch) { - const [, , name, description] = paramMatch; - result.params[name] = description; - } - } else if (line.startsWith('@returns')) { - // Format: @returns {type} description - const returnsMatch = line.match(/@returns\s+\{([^}]+)\}\s+(.*)/); - if (returnsMatch) { - const [, , description] = returnsMatch; - result.returns = description; - } - } else if (!line.startsWith('@')) { - if (currentSection === 'description') { - result.description += (result.description ? '\n' : '') + line; - } - } + + const pathFunction = api[methodName]; + if (typeof pathFunction === 'function') { + const functionStr = pathFunction.toString(); + const urlMatch = functionStr.match(/['"`](.*?)['"`]/); + if (urlMatch) { + let url = urlMatch[1]; + paramNames.forEach(param => { + url = url.replace( + new RegExp(`\\$\\{${param}\\}`, 'g'), + `{${param}}` + ); + }); + return url; } - - return result; } - - function extractJSDocComment(node: ts.Node, sourceFile: ts.SourceFile): string { - const ranges = ts.getLeadingCommentRanges(sourceFile.text, node.pos); - if (!ranges || ranges.length === 0) return ''; - const commentRange = ranges[ranges.length - 1]; - return sourceFile.text.substring(commentRange.pos, commentRange.end); - } - - export function getMethodDocs(node: ts.MethodDeclaration, sourceFile: ts.SourceFile): MethodDoc | null { - const nameIdentifier = node.name as ts.Identifier; - const name = nameIdentifier.escapedText.toString(); - if (!name || name.startsWith('_') || ['fetch', 'fetchPost'].includes(name)) return null; - - const jsDoc = parseJSDocComment(extractJSDocComment(node, sourceFile)); - const start = node.pos; - const end = node.end; - const methodText = sourceFile.text.slice(start, end); - - return { - name, - parameters: node.parameters.map(param => { - const paramName = (param.name as ts.Identifier).escapedText.toString(); - const paramType = param.type - ? sourceFile.text.slice(param.type.pos, param.type.end).trim() - : 'any'; - - return { - name: paramName, - type: paramType, - description: jsDoc.params[paramName] || '' - }; - }), - description: jsDoc.description, - endpoint: API_MAP[name] || '', - httpMethod: methodText.includes('this.fetchPost') ? 'POST' : 'GET', - returnType: node.type?.getText(sourceFile) || 'Promise', - responseSchema: (() => { - const fetchMatch = methodText.match(/this\.fetch\([^,]+,\s*([\w.()]+)/); - if (!fetchMatch) return ''; - - const schema = fetchMatch[1]; - if (schema.includes('z.array')) { - const arrayMatch = schema.match(/z\.array\((\w+Schema)\)/); - return arrayMatch?.[1] || ''; - } - if (schema.includes('z.number')) return 'z.number()'; - - return schema; - })(), - }; - } \ No newline at end of file + return ''; +} \ No newline at end of file diff --git a/docs/generateTypesDocs.ts b/docs/generateTypesDocs.ts index 8c30b78..015fb14 100644 --- a/docs/generateTypesDocs.ts +++ b/docs/generateTypesDocs.ts @@ -1,186 +1,146 @@ -import * as fs from 'fs'; -import * as path from 'path'; +import * as ts from 'typescript'; +import { TypeDocumentation, TypeProperty } from './types'; +import { parseZodType } from './utils/zod-type-parser'; +import { extractJSDoc } from './utils/jsdoc-extractor'; -interface Property { - name: string; - type: string; - description: string; -} - -export interface CustomType { - name: string; - type: 'enum' | 'object'; - description: string; - properties?: Property[]; - values?: string[]; - sourceFile: string; -} - -function parseZodType(zodType: string): string { - const typeMap = { - 'z.string()': 'string', - 'z.number()': 'number', - 'z.boolean()': 'boolean' - }; - - const type = zodType.trim(); +export function generateTypeDocs( + sourceFile: ts.SourceFile, + program: ts.Program +): TypeDocumentation[] { + const types: TypeDocumentation[] = []; - if (type.endsWith('Schema')) { - return type.replace('Schema', ''); - } - - if (type.includes('.nullable()')) { - const baseType = type.replace('.nullable()', ''); - return `${parseZodType(baseType)} | null`; - } - - for (const [key, baseType] of Object.entries(typeMap)) { - if (type.startsWith(key)) { - return baseType; + // First collect all schema names from this file + const schemaNames: string[] = []; + ts.forEachChild(sourceFile, node => { + if (ts.isVariableStatement(node)) { + const declaration = node.declarationList.declarations[0]; + if (ts.isIdentifier(declaration.name) && + declaration.name.text.endsWith('Schema')) { + schemaNames.push(declaration.name.text); + } } + }); + + if (schemaNames.length === 0) { + return []; } - - if (type.startsWith('z.array')) { - const innerMatch = type.match(/z\.array\((.*)\)/); - if (!innerMatch) return 'unknown[]'; - return `${parseZodType(innerMatch[1])}[]`; - } - - if (type.startsWith('z.record')) { - const matches = type.match(/z\.record\((.*?),\s*(.*?)\)/); - if (!matches) return 'Record'; - const [_, keyType, valueType] = matches; - return `Record<${parseZodType(keyType)}, ${parseZodType(valueType)}>`; - } - - if (type.startsWith('z.tuple')) { - const innerMatch = type.match(/z\.tuple\(\[(.*)\]\)/); - if (!innerMatch) return '[unknown]'; - const types = innerMatch[1].split(',').map(t => parseZodType(t.trim())); - return `[${types.join(', ')}]`; - } - - if (type.includes('z.enum')) { - return type.match(/\[(.*?)\]/)?.[1] - .split(',') - .map(v => v.trim().replace(/['"]/g, '')) - .join(' | ') || 'enum'; - } - - return type.replace('z.', ''); -} -function findTypeDescription(typeName: string, typeFileContent: string): string { - // Look for JSDoc comment followed by the type definition - const regex = new RegExp( - `/\\*\\*\\s*\\n\\s*\\*\\s*([^\\n]+)\\s*\\n[^/]*\\*/\\s*export\\s+type\\s+${typeName}\\s*=`, - 'ms' + // Find types/index.ts file for documentation + const typesFile = program.getSourceFiles().find(sf => + sf.fileName.includes('types/index.ts') ); - - const match = regex.exec(typeFileContent); - return match ? match[1].trim() : ''; -} -export function extractZodSchema(schemaContent: string, filename: string): CustomType[] { - // Try to read types file - let typeFileContent = ''; - try { - const typeFilePath = path.join(process.cwd(), 'src', 'types', 'index.ts'); - if (fs.existsSync(typeFilePath)) { - typeFileContent = fs.readFileSync(typeFilePath, 'utf8'); - } - } catch (error) { - console.warn('Could not read types file:', error); - } + // Get documentation from types/index.ts + const typeDocumentation = typesFile + ? extractJSDoc( + typesFile, + node => ts.isTypeAliasDeclaration(node) && + schemaNames.some(schema => + node.name.text === schema.replace('Schema', '') + ) + ) + : new Map(); + + // Process schemas with documentation + ts.forEachChild(sourceFile, node => { + if (!ts.isVariableStatement(node)) return; - const schemaRegex = /export\s+const\s+(\w+Schema)\s*=\s*z\.([\s\S]*?);(?=\s*(?:export|$))/g; - const types: CustomType[] = []; - let match; + const declaration = node.declarationList.declarations[0]; + if (!ts.isIdentifier(declaration.name) || + !declaration.name.text.endsWith('Schema')) { + return; + } - while ((match = schemaRegex.exec(schemaContent)) !== null) { - const [fullMatch, schemaName, definition] = match; + const schemaName = declaration.name.text; const typeName = schemaName.replace('Schema', ''); - const description = findTypeDescription(typeName, typeFileContent); + const docs = typeDocumentation.get(`type:${typeName}`); + const description = docs?.description || `Type defined in ${sourceFile.fileName}`; + + const initializer = declaration.initializer; + if (!initializer) return; - if (definition.includes('enum')) { - const enumMatch = fullMatch.match(/\[(.*?)\]/s); - const enumValues = enumMatch ? - enumMatch[1] - .split(',') - .map(v => v.trim().replace(/['"]/g, '')) - .filter(Boolean) - .sort() : []; - - types.push({ - name: typeName, - type: 'enum', + const typeText = sourceFile.text.slice(initializer.pos, initializer.end); + + if (typeText.includes('z.enum')) { + types.push(extractEnumType( + typeName, + typeText, description, - values: enumValues, - sourceFile: filename - }); - } else if (definition.includes('object')) { - const properties: Property[] = []; - const propsMatch = definition.match(/object\(\{([\s\S]*?)\}\)/s); - - if (propsMatch) { - const propsContent = propsMatch[1]; - const propLines = propsContent.split('\n'); - - const props = propLines - .map(line => { - const propMatch = line.match(/^\s*(\w+):\s*(.*?)(?:,\s*$|$)/); - if (propMatch) { - const [_, propName, propType] = propMatch; - if (propName && propType) { - return { - name: propName.trim(), - type: parseZodType(propType.trim()), - description: '' - }; - } - } - return null; - }) - .filter((prop): prop is Property => prop !== null) - .sort((a, b) => a.name.localeCompare(b.name)); - - properties.push(...props); - } - - types.push({ - name: typeName, - type: 'object', + sourceFile.fileName + )); + } else if (typeText.includes('z.object')) { + const objectType = extractObjectType( + typeName, + initializer, description, - properties, - sourceFile: filename - }); + docs?.params || {}, + sourceFile.fileName, + program.getTypeChecker() + ); + types.push(objectType); } - } + }); - return types; + return types.sort((a, b) => a.name.localeCompare(b.name)); } -export function getAllTypesByFiles(files: string[]): CustomType[] { - const sortedFiles = [...files].sort((a, b) => path.basename(a).localeCompare(path.basename(b))); - const typesMap = new Map(); +function extractEnumType( + name: string, + typeText: string, + description: string, + sourceFile: string +): TypeDocumentation { + const enumMatch = typeText.match(/\[(.*?)\]/s); + const values = enumMatch + ? enumMatch[1] + .split(',') + .map(v => v.trim().replace(/['"]/g, '')) + .filter(Boolean) + .sort() + : []; + + return { + name, + kind: 'enum', + description, + values, + sourceFile + }; +} + +function extractObjectType( + name: string, + node: ts.Expression, + description: string, + paramDocs: Record, + sourceFile: string, + typeChecker: ts.TypeChecker +): TypeDocumentation { + const properties: TypeProperty[] = []; - for (const file of sortedFiles) { - if (!file.endsWith('.ts')) continue; - - const content = fs.readFileSync(file, 'utf8'); - if (content.includes('z.object') || content.includes('z.enum')) { - const types = extractZodSchema(content, path.basename(file)); - typesMap.set(file, types); + if (ts.isCallExpression(node)) { + const objectArg = node.arguments[0]; + if (ts.isObjectLiteralExpression(objectArg)) { + objectArg.properties.forEach(prop => { + if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.name)) { + const propName = prop.name.text; + const propType = parseZodType(prop.initializer.getText()); + + properties.push({ + name: propName, + type: propType, + description: paramDocs[propName] || '' + }); + } + }); } } - const allTypes: CustomType[] = []; - for (const file of sortedFiles) { - const types = typesMap.get(file); - if (types) { - allTypes.push(...types); - } - } - - return allTypes; + return { + name, + kind: 'object', + description, + properties: properties.sort((a, b) => a.name.localeCompare(b.name)), + sourceFile + }; } \ No newline at end of file diff --git a/docs/scripts.js b/docs/scripts.js new file mode 100644 index 0000000..f1069e8 --- /dev/null +++ b/docs/scripts.js @@ -0,0 +1,99 @@ +function initializeTabs() { + const buttons = document.querySelectorAll('button[data-tab]'); + const sections = { + methods: document.getElementById('methods-content'), + types: document.getElementById('types-content') + }; + const scrollPositions = { + methods: 0, + types: 0 + }; + + buttons.forEach(button => { + button.addEventListener('click', () => { + const tab = button.getAttribute('data-tab'); + + const activeTab = [...buttons] + .find(b => b.classList.contains('active')) + ?.getAttribute('data-tab'); + + if (activeTab) { + scrollPositions[activeTab] = sections[activeTab].scrollTop; + } + + buttons.forEach(b => { + if (b === button) { + b.classList.add('active'); + } else { + b.classList.remove('active'); + } + }); + + Object.values(sections).forEach(section => { + section.classList.remove('active'); + }); + + if (sections[tab]) { + sections[tab].classList.add('active'); + sections[tab].scrollTop = scrollPositions[tab]; + } else { + console.error('Section not found:', tab); + } + }); + }); + + Object.entries(sections).forEach(([tab, section]) => { + if (section) { + section.addEventListener('scroll', () => { + scrollPositions[tab] = section.scrollTop; + }); + } else { + console.error('Section not found for scroll listener:', tab); + } + }); +} + +function initializeCollapsibles() { + const triggers = document.querySelectorAll('[data-collapse-trigger]'); + + triggers.forEach(trigger => { + trigger.addEventListener('click', () => { + const content = trigger.nextElementSibling; + const arrow = trigger.querySelector('svg'); + + if (content && arrow) { + content.classList.toggle('active'); + arrow.style.transform = content.classList.contains('active') + ? 'rotate(180deg)' + : ''; + } else { + console.error('Missing content or arrow for trigger:', trigger); + } + }); + }); +} + +function initializeTypeLinks() { + document.addEventListener('click', e => { + if (e.target.matches('a[href^="#"]')) { + const typesTab = document.querySelector('[data-tab="types"]'); + if (typesTab) { + typesTab.click(); + } else { + console.error('Types tab button not found'); + } + } + }); +} + +function initializeDocumentation() { + initializeTabs(); + initializeCollapsibles(); + initializeTypeLinks(); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', initializeDocumentation); +} else { + initializeDocumentation(); +} \ No newline at end of file diff --git a/docs/shared-utils.ts b/docs/shared-utils.ts new file mode 100644 index 0000000..fc41127 --- /dev/null +++ b/docs/shared-utils.ts @@ -0,0 +1,96 @@ +import * as ts from 'typescript'; +import * as fs from 'fs'; +import * as path from 'path'; +import { TypeDocumentation, MethodDocumentation } from './types'; + +export type ExcludePattern = RegExp | ((path: string) => boolean); + +export interface FileProcessingOptions { + sourceDir: string; + excludePatterns?: ExcludePattern[]; + includeExtensions?: string[]; +} + +/** + * Gets all files recursively from a directory matching specified criteria + */ +export function getAllFiles({ + sourceDir, + excludePatterns = [/node_modules/], + includeExtensions = ['.ts'] +}: FileProcessingOptions): string[] { + if (!fs.existsSync(sourceDir)) { + return []; + } + + const files = fs.readdirSync(sourceDir); + return files.flatMap(file => { + const fullPath = path.join(sourceDir, file); + + const shouldExclude = excludePatterns.some(pattern => + pattern instanceof RegExp + ? pattern.test(fullPath) + : pattern(fullPath) + ); + + if (shouldExclude) { + return []; + } + + if (fs.statSync(fullPath).isDirectory()) { + return getAllFiles({ + sourceDir: fullPath, + excludePatterns, + includeExtensions + }); + } + + return includeExtensions.some(ext => file.endsWith(ext)) ? [fullPath] : []; + }); +} + +/** + * Creates a TypeScript program from source files + */ +export function createTSProgram(sourceFiles: string[]): ts.Program { + const options: ts.CompilerOptions = { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.ESNext, + allowJs: true, + checkJs: true, + noEmit: true, + types: ['node'], + skipLibCheck: true, + }; + + return ts.createProgram(sourceFiles, options); +} + +/** + * Ensures output directory exists and writes documentation to JSON file + */ +export function writeDocs( + outputPath: string, + documentation: { methods: MethodDocumentation[]; types: TypeDocumentation[]; } +): void { + const outputDir = path.dirname(outputPath); + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + fs.writeFileSync( + outputPath, + JSON.stringify(documentation, null, 2) + ); +} + +/** + * Node visitor helper for TypeScript AST + */ +export function visitNodes( + node: ts.Node, + visitor: (node: ts.Node) => void +): void { + visitor(node); + ts.forEachChild(node, node => visitNodes(node, visitor)); +} \ No newline at end of file diff --git a/docs/styles.css b/docs/styles.css new file mode 100644 index 0000000..31549da --- /dev/null +++ b/docs/styles.css @@ -0,0 +1,213 @@ +:root { + --color-base: rgba(240, 240, 235, 0.95); + --color-muted: rgba(200, 200, 195, 0.85); + --color-accent: rgba(220, 220, 215, 0.07); + --color-background: #0a0a0a; + --color-border: rgba(160, 160, 155, 0.15); + --color-hover: rgba(240, 240, 235, 0.1); + --color-active: rgba(240, 240, 235, 0.15); + --color-method: rgba(240, 240, 235, 0.1); + + --header-height: 4.5rem; + --header-height-sm: 7rem; + + --text-body-sm: 0.75rem; + --text-body: 0.875rem; + --text-title: 1.125rem; + --text-header: 1.5rem; + + --space-sm: 0.5rem; + --space-md: 1rem; + --space-lg: 1.5rem; + + --border-opacity: 0.12; + --hover-opacity: 1; + + --header-border-width: 1px; + --header-border-opacity: 0.08; +} + +html, body { + height: 100%; + overflow: hidden; +} + +@keyframes scanline { + 0% { + transform: translateY(-100%); + } + 100% { + transform: translateY(100%); + } +} + +body::after { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: linear-gradient( + transparent 50%, + rgba(240, 240, 235, 0.01) 50% + ); + background-size: 100% 3px; + pointer-events: none; + z-index: 100; +} + +.main-layout { + height: 100vh; + display: flex; + flex-direction: column; +} + +.content-wrapper { + flex: 1; + overflow: hidden; + position: relative; +} + +.tab-content { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + overflow-y: auto; + display: none; + padding: var(--space-md); +} + +@media (min-width: 640px) { + .tab-content { + padding: var(--space-lg); + } + :root { + --header-height: 5rem; + } +} + +.tab-content.active { + display: block; +} + +.type-link { + text-decoration: underline; + text-decoration-color: var(--color-muted); + text-underline-offset: 2px; + opacity: 0.85; +} + +.type-link:hover { + opacity: var(--hover-opacity); + color: var(--color-base); +} + +.section-content { + display: none; +} + +.section-content.active { + display: block; +} + +::selection { + background: rgba(240, 240, 235, 0.2); + color: var(--color-base); +} + +.nav-button { + font-size: var(--text-body); + padding: var(--space-sm) var(--space-md); + border-radius: 0.25rem; + transition: all 0.15s ease; + border: 1px solid var(--color-border); + color: var(--color-muted); + background: transparent; +} + +.nav-button:hover { + border-color: var(--color-muted); + color: var(--color-base); + background: var(--color-hover); +} + +.nav-button.active { + border-color: var(--color-base); + background-color: var(--color-active); + color: var(--color-base); +} + +.doc-section { + border: 1px solid var(--color-border); + border-radius: 0.375rem; + background-color: var(--color-background); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); +} + +.doc-header { + border-bottom: 1px solid var(--color-border); + background-color: var(--color-accent); + padding: var(--space-md) var(--space-lg); +} + +.github-button { + border-color: var(--color-border) !important; + color: var(--color-muted) !important; + background: transparent !important; +} + +.github-button:hover { + border-color: var(--color-muted) !important; + color: var(--color-base) !important; + background: var(--color-hover) !important; +} + +.method-badge { + background-color: var(--color-method); + border: 1px solid var(--color-border); + color: var(--color-base); + font-weight: 500; + padding: 0.25rem 0.75rem; + border-radius: 0.25rem; +} + +.collapse-trigger { + transition: background-color 0.15s ease; +} + +.collapse-trigger:hover { + background-color: var(--color-hover); +} + +code { + color: var(--color-base); +} + +.parameter-block { + background-color: var(--color-accent); + border: 1px solid var(--color-border); + border-radius: 0.375rem; +} + +.sticky-header { + border-bottom: var(--header-border-width) solid var(--color-border); + background-color: var(--color-background); + backdrop-filter: blur(8px); + flex-shrink: 0; +} + +button, a { + transition: all 0.15s ease; +} + +.tab-content { + scrollbar-width: none; + -ms-overflow-style: none; +} + +.tab-content::-webkit-scrollbar { + display: none; +} \ No newline at end of file diff --git a/docs/types.ts b/docs/types.ts new file mode 100644 index 0000000..d70ef8e --- /dev/null +++ b/docs/types.ts @@ -0,0 +1,85 @@ +import * as ts from 'typescript'; + +/** + * Base interface for documented items + */ +export interface DocumentedItem { + name: string; + description: string; + sourceFile: string; +} + +/** + * Represents a method parameter + */ +export interface Parameter { + name: string; + type: string; + description: string; +} + +/** + * Documentation for an API method + */ +export interface MethodDocumentation extends DocumentedItem { + parameters: Parameter[]; + endpoint: string; + httpMethod: 'GET' | 'POST'; + returnType: string; + recursive: boolean; +} + +/** + * Represents a property in a type definition + */ +export interface TypeProperty extends Parameter {} + +/** + * Documentation for a type definition + */ +export interface TypeDocumentation extends DocumentedItem { + kind: 'enum' | 'object'; + properties?: TypeProperty[]; + values?: string[]; +} + +/** + * Complete API documentation + */ +export interface Documentation { + methods: MethodDocumentation[]; + types: TypeDocumentation[]; +} + +/** + * Represents parsed JSDoc comments + */ +export interface ParsedComment { + description: string; + params: Record; + returns?: string; + example?: string; +} + +/** + * Configuration for processing source files + */ +export interface FileProcessingConfig { + sourceDir: string; + excludePatterns?: RegExp[]; + includeExtensions?: string[]; +} + +/** + * Options for generating documentation + */ +export interface GenerateOptions { + outputDir: string; + sourceDir: string; + typeDescriptionsPath?: string; +} + +/** + * Node visitor function type + */ +export type NodeVisitor = (node: ts.Node) => void; \ No newline at end of file diff --git a/docs/utils/jsdoc-extractor.ts b/docs/utils/jsdoc-extractor.ts new file mode 100644 index 0000000..ba5e8f4 --- /dev/null +++ b/docs/utils/jsdoc-extractor.ts @@ -0,0 +1,134 @@ +import * as ts from 'typescript'; + +export interface JSDocInfo { + description: string; + params: Record; + returns?: string; + example?: string; + tags: Record; +} + +export function extractJSDoc( + sourceFile: ts.SourceFile, + predicate: (node: ts.Node) => boolean = () => true +): Map { + const docs = new Map(); + + function visit(node: ts.Node) { + if (predicate(node)) { + const nodeIdentifier = getNodeIdentifier(node); + if (nodeIdentifier) { + const documentation = extractNodeDocs(node, sourceFile); + if (documentation.description || Object.keys(documentation.params).length > 0) { + docs.set(nodeIdentifier, documentation); + } + } + } + ts.forEachChild(node, visit); + } + + visit(sourceFile); + console.log(`Extracted ${docs.size} documentation entries from ${sourceFile.fileName}`); + return docs; +} + +function getNodeIdentifier(node: ts.Node): string | null { + if (ts.isMethodDeclaration(node) && ts.isIdentifier(node.name)) { + return `method:${node.name.text}`; + } + if (ts.isTypeAliasDeclaration(node)) { + return `type:${node.name.text}`; + } + if (ts.isInterfaceDeclaration(node)) { + return `interface:${node.name.text}`; + } + if (ts.isClassDeclaration(node) && node.name) { + return `class:${node.name.text}`; + } + if (ts.isFunctionDeclaration(node) && node.name) { + return `function:${node.name.text}`; + } + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + return `variable:${node.name.text}`; + } + return null; +} + +function cleanDocText(text: string): string { + return text + .trim() + .replace(/^[-–—*]\s*/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function extractNodeDocs(node: ts.Node, sourceFile: ts.SourceFile): JSDocInfo { + const result: JSDocInfo = { + description: '', + params: {}, + tags: {} + }; + + // Get JSDoc nodes + const jsDocs = ((node as any).jsDoc || []) as ts.JSDoc[]; + + for (const jsDoc of jsDocs) { + // Extract description from the main JSDoc comment + if (jsDoc.comment) { + const commentText = typeof jsDoc.comment === 'string' + ? jsDoc.comment + : Array.isArray(jsDoc.comment) + ? jsDoc.comment.map(part => part.text).join(' ') + : ''; + + result.description += (result.description ? ' ' : '') + cleanDocText(commentText); + } + + // Process JSDoc tags + if (jsDoc.tags) { + for (const tag of jsDoc.tags) { + if (ts.isJSDocParameterTag(tag) && tag.name) { + const paramName = tag.name.getText(); + const comment = tag.comment + ? (typeof tag.comment === 'string' ? tag.comment : tag.comment.map(part => part.text).join(' ')) + : ''; + result.params[paramName] = cleanDocText(comment); + } + else if (ts.isJSDocReturnTag(tag)) { + const comment = tag.comment + ? (typeof tag.comment === 'string' ? tag.comment : tag.comment.map(part => part.text).join(' ')) + : ''; + result.returns = cleanDocText(comment); + } + else if (ts.isJSDoc(tag)) { + const tagName = (tag as any).tagName?.escapedText; + if (tagName) { + const comment = tag.comment + ? (typeof tag.comment === 'string' ? tag.comment : tag.comment.map(part => part.text).join(' ')) + : ''; + result.tags[tagName] = cleanDocText(comment); + } + } + } + } + } + + // Fallback to looking for leading comments if no JSDoc is found + if (!result.description) { + const commentRanges = ts.getLeadingCommentRanges( + sourceFile.text, + node.getFullStart() + ); + + if (commentRanges?.length) { + const commentRange = commentRanges[commentRanges.length - 1]; + result.description = cleanDocText( + sourceFile.text + .slice(commentRange.pos, commentRange.end) + .replace(/\/\*\*|\*\/|\*/g, '') + ); + } + } + + return result; +} \ No newline at end of file diff --git a/docs/utils/zod-type-parser.ts b/docs/utils/zod-type-parser.ts new file mode 100644 index 0000000..5041eb6 --- /dev/null +++ b/docs/utils/zod-type-parser.ts @@ -0,0 +1,59 @@ +/** + * Converts a Zod type string into its TypeScript equivalent + */ +export function parseZodType(zodType: string): string { + const typeMap = { + 'z.string()': 'string', + 'z.number()': 'number', + 'z.boolean()': 'boolean' + }; + + const type = zodType.trim(); + + if (type.endsWith('Schema')) { + return type.replace('Schema', ''); + } + + if (type.includes('.nullable()')) { + const baseType = type.replace('.nullable()', ''); + return `${parseZodType(baseType)} | null`; + } + + for (const [key, baseType] of Object.entries(typeMap)) { + if (type.startsWith(key)) { + return baseType; + } + } + + if (type.startsWith('z.array')) { + const innerMatch = type.match(/z\.array\((.*)\)/); + if (!innerMatch) return 'unknown[]'; + return `${parseZodType(innerMatch[1])}[]`; + } + + if (type.startsWith('z.record')) { + const matches = type.match(/z\.record\((.*?),\s*([\s\S]*?)\)(?![\s\S]*\)$)/); + if (!matches) return 'Record'; + const [_, keyType, valueType] = matches; + const parsedValueType = parseZodType(valueType.trim()); + const parsedKeyType = parseZodType(keyType.trim()); + + return `Record<${parsedKeyType}, ${parsedValueType}>`; + } + + if (type.startsWith('z.tuple')) { + const innerMatch = type.match(/z\.tuple\(\[(.*)\]\)/); + if (!innerMatch) return '[unknown]'; + const types = innerMatch[1].split(',').map(t => parseZodType(t.trim())); + return `[${types.join(', ')}]`; + } + + if (type.includes('z.enum')) { + return type.match(/\[(.*?)\]/)?.[1] + .split(',') + .map(v => v.trim().replace(/['"]/g, '')) + .join(' | ') || 'enum'; + } + + return type.replace('z.', ''); + } \ No newline at end of file diff --git a/src/api.ts b/src/api.ts index d208c6e..b5a53ca 100644 --- a/src/api.ts +++ b/src/api.ts @@ -1,41 +1,55 @@ import { OutputType } from './types'; const api = { - address: (address: string) => `/address/${address}`, - block: (heightOrHash: number | string) => `/block/${heightOrHash}`, - blockcount: '/blockcount', - blockhash: { - latest: '/blockhash', - byHeight: (height: number) => `/blockhash/${height}`, + getAddressInfo: (address: string) => `/address/${address}`, + getBlockInfo: (heightOrHash: number | string) => `/block/${heightOrHash}`, + getBlockCount: '/blockcount', + getBlockHashByHeight: (height: number) => `/blockhash/${height}`, + getBlockHash: '/blockhash', + getBlockHeight: '/blockheight', + getBlocksLatest: '/blocks', + getBlockTime: '/blocktime', + getInscriptionInfo: (id: string) => `/inscription/${id}`, + getChild: (id: string, child: number) => `/inscription/${id}/${child}`, + getInscriptions: '/inscriptions', + getInscriptionsByIds: '/inscriptions', + getInscriptionsByPage: (page: number) => `/inscriptions/${page}`, + getInscriptionsByBlock: (height: number) => `/inscriptions/block/${height}`, + getOutput: (outpoint: string) => `/output/${outpoint}`, + getOutputs: '/outputs', + getOutputsByAddress: (address: string, type?: OutputType) => { + const base = `/outputs/${address}`; + return type ? `${base}?type=${type}` : base; }, - blockheight: '/blockheight', - blocks: '/blocks', - blocktime: '/blocktime', - inscription: (id: string) => `/inscription/${id}`, - inscriptionChild: (id: string, child: number) => - `/inscription/${id}/${child}`, - inscriptions: { - base: '/inscriptions', - latest: '/inscriptions', - byPage: (page: number) => `/inscriptions/${page}`, - byBlock: (height: number) => `/inscriptions/block/${height}`, - }, - output: (outpoint: string) => `/output/${outpoint}`, - outputs: { - base: '/outputs', - byAddress: (address: string, type?: OutputType) => { - const base = `/outputs/${address}`; - return type ? `${base}?type=${type}` : base; - }, - }, - rune: (name: string) => `/rune/${name}`, - runes: { - latest: '/runes', - byPage: (page: number) => `/runes/${page}`, - }, - sat: (number: number) => `/sat/${number}`, - tx: (txId: string) => `/tx/${txId}`, - status: '/status', + getRune: (name: string) => `/rune/${name}`, + getRunesLatest: '/runes', + getRunesByPage: (page: number) => `/runes/${page}`, + getSat: (number: number) => `/sat/${number}`, + getTransaction: (txId: string) => `/tx/${txId}`, + getServerStatus: '/status', + + // recursive endpoints + getBlockHashByHeightRecursive: (height: number) => `/r/blockhash/${height}`, + getBlockHashRecursive: '/r/blockhash', + getBlockHeightRecursive: '/r/blockheight', + getBlockInfoRecursive: (heightOrHash: number | string) => + `/r/blockinfo/${heightOrHash}`, + getBlockTimeRecursive: '/r/blocktime', + getChildren: (id: string) => `/r/children/${id}`, + getChildrenByPage: (id: string, page: number) => `/r/children/${id}/${page}`, + getChildrenInfo: (id: string) => `/r/children/${id}/inscriptions`, + getChildrenInfoByPage: (id: string, page: number) => + `/r/children/${id}/inscriptions/${page}`, + getInscriptionRecursive: (id: string) => `/r/inscription/${id}`, + getParents: (id: string) => `/r/parents/${id}`, + getParentsByPage: (id: string, page: number) => `/r/parents/${id}/${page}`, + getInscriptionsOnSat: (number: number) => `/r/sat/${number}`, + getInscriptionsOnSatByPage: (number: number, page: number) => + `/r/sat/${number}/${page}`, + getInscriptionOnSat: (number: number, index: number) => + `/r/sat/${number}/at/${index}`, + getTransactionHex: (txid: string) => `/r/tx/${txid}`, + getOutputAssets: (outpoint: string) => `/r/utxo/${outpoint}`, } as const; export default api; diff --git a/src/client.ts b/src/client.ts index d42aba0..d898761 100644 --- a/src/client.ts +++ b/src/client.ts @@ -13,6 +13,13 @@ import { SatInfoSchema, ServerStatusSchema, TransactionInfoSchema, + BlockDetailsSchema, + InscriptionsIDsResponseSchema, + ChildrenInfoResponseSchema, + InscriptionIDSchema, + OutputAssetsSchema, + TransactionHexSchema, + InscriptionRecursiveSchema, } from './schemas'; import type { BlockInfo, @@ -28,6 +35,13 @@ import type { TransactionInfo, ServerStatus, OutputType, + BlockDetails, + InscriptionsIDsResponse, + ChildrenInfoResponse, + InscriptionID, + OutputAssets, + TransactionHex, + InscriptionRecursive, } from './types'; type ApiResponse = @@ -110,119 +124,152 @@ export class OrdClient { * inscriptions, and rune balances. * * @param {string} address - Bitcoin address to query. - * @returns {Promise} Address details including outputs and balances. */ async getAddressInfo(address: string): Promise { - return this.fetch(api.address(address), AddressInfoSchema); + return this.fetch(api.getAddressInfo(address), AddressInfoSchema); } /** * Fetches details about a specific block by its height or hash. * * @param {number | BlockHash} heightOrHash - Block height (number) or block hash (string). - * @returns {Promise} Detailed information about the block. */ - async getBlock(heightOrHash: number | BlockHash): Promise { - return this.fetch(api.block(heightOrHash), BlockInfoSchema); + async getBlockInfo(heightOrHash: number | BlockHash): Promise { + return this.fetch(api.getBlockInfo(heightOrHash), BlockInfoSchema); } /** - * Retrieves the total number of blocks in the blockchain. + * Gets detailed block information using the recursive endpoint. * - * @returns {Promise} Current block count + * @param {number | string} heightOrHash - Block height or hash + */ + async getBlockInfoRecursive( + heightOrHash: number | string, + ): Promise { + return this.fetch( + api.getBlockInfoRecursive(heightOrHash), + BlockDetailsSchema, + ); + } + + /** + * Retrieves the total number of blocks in the blockchain. */ async getBlockCount(): Promise { - return this.fetch(api.blockcount, z.number().int().nonnegative()); + return this.fetch(api.getBlockCount, z.number().int().nonnegative()); + } + + /** + * Gets the hash of the latest block. + */ + async getBlockHash(): Promise { + return this.fetch(api.getBlockHash, BlockHashSchema); + } + + /** + * Gets the latest block hash using the recursive endpoint. + * + * @returns {Promise} Latest block hash + */ + async getBlockHashRecursive(): Promise { + return this.fetch(api.getBlockHashRecursive, BlockHashSchema); } /** * Gets the hash of a block at the specified height. * * @param {number} height - Block height to get hash for - * @returns {Promise} Block hash */ async getBlockHashByHeight(height: number): Promise { - return this.fetch(api.blockhash.byHeight(height), BlockHashSchema); + return this.fetch(api.getBlockHashByHeight(height), BlockHashSchema); } /** - * Gets the hash of the latest block. + * Gets the block hash using the recursive endpoint. * - * @returns {Promise} Latest block hash + * @param {number} height - Block height */ - async getLatestBlockHash(): Promise { - return this.fetch(api.blockhash.latest, BlockHashSchema); + async getBlockHashByHeightRecursive(height: number): Promise { + return this.fetch( + api.getBlockHashByHeightRecursive(height), + BlockHashSchema, + ); } /** * Gets the height of the latest block. - * - * @returns {Promise} Latest block height */ - async getLatestBlockHeight(): Promise { - return this.fetch(api.blockheight, z.number().int().nonnegative()); + async getBlockHeight(): Promise { + return this.fetch(api.getBlockHeight, z.number().int().nonnegative()); + } + + /** + * Gets the latest block height using the recursive endpoint. + */ + async getBlockHeightRecursive(): Promise { + return this.fetch( + api.getBlockHeightRecursive, + z.number().int().nonnegative(), + ); } /** * Returns the height of the latest block, the blockhashes of the last 100 blocks, and featured inscriptions from them. - * - * @returns {Promise} Latest blocks information */ - async getLatestBlocks(): Promise { - return this.fetch(api.blocks, BlocksResponseSchema); + async getBlocksLatest(): Promise { + return this.fetch(api.getBlocksLatest, BlocksResponseSchema); } /** * Gets the timestamp of the latest block. - * - * @returns {Promise} Latest block's Unix timestamp */ - async getLatestBlockTime(): Promise { - return this.fetch(api.blocktime, z.number().int()); + async getBlockTime(): Promise { + return this.fetch(api.getBlockTime, z.number().int()); + } + + /** + * Gets block time using the recursive endpoint. + */ + async getBlockTimeRecursive(): Promise { + return this.fetch(api.getBlockTimeRecursive, z.number().int()); } /** * Retrieves information about a specific inscription by its ID. * * @param {string} id - Inscription ID - * @returns {Promise} Detailed information about the inscription */ - async getInscription(id: string): Promise { - return this.fetch(api.inscription(id), InscriptionInfoSchema); + async getInscriptionInfo(id: string): Promise { + return this.fetch(api.getInscriptionInfo(id), InscriptionInfoSchema); } /** - * Gets a specific child inscription of a parent inscription. + * Gets recursive inscription information. * - * @param {string} id - Parent inscription ID - * @param {number} child - Index of the child inscription - * @returns {Promise} Child inscription details + * @param {string} id - Inscription ID */ - async getInscriptionChild( - id: string, - child: number, - ): Promise { - return this.fetch(api.inscriptionChild(id, child), InscriptionInfoSchema); + async getInscriptionRecursive(id: string): Promise { + return this.fetch( + api.getInscriptionRecursive(id), + InscriptionRecursiveSchema, + ); } /** * Gets a list of the 100 most recent inscriptions. - * - * @returns {Promise} Latest inscriptions information */ - async getLatestInscriptions(): Promise { - return this.fetch(api.inscriptions.latest, InscriptionsResponseSchema); + async getInscriptions(): Promise { + return this.fetch(api.getInscriptions, InscriptionsResponseSchema); } /** * Retrieves information about multiple inscriptions by their IDs. * * @param {string[]} ids - Array of inscription IDs to fetch - * @returns {Promise} Array of inscription details */ async getInscriptionsByIds(ids: string[]): Promise { return this.fetchPost( - api.inscriptions.base, + api.getInscriptionsByIds, ids, z.array(InscriptionInfoSchema), ); @@ -232,11 +279,10 @@ export class OrdClient { * Gets inscriptions for a specific page number in paginated results. * * @param {number} page - Page number to fetch - * @returns {Promise} Page of inscriptions */ async getInscriptionsByPage(page: number): Promise { return this.fetch( - api.inscriptions.byPage(page), + api.getInscriptionsByPage(page), InscriptionsResponseSchema, ); } @@ -245,11 +291,139 @@ export class OrdClient { * Gets all inscriptions in a specific block. * * @param {number} height - Block height to fetch inscriptions from - * @returns {Promise} Block's inscriptions */ async getInscriptionsByBlock(height: number): Promise { return this.fetch( - api.inscriptions.byBlock(height), + api.getInscriptionsByBlock(height), + InscriptionsResponseSchema, + ); + } + + /** + * Gets ID of a specific inscription at an index by sat number. The inscription id at index of all inscriptions on a sat. Index may be a negative number to index from the back. 0 being the first and -1 being the most recent for example. Requires index with --index-sats flag. + * + * @param {number} number - Satoshi number + * @param {number} index - Inscription index + */ + async getInscriptionOnSat( + number: number, + index: number, + ): Promise { + return this.fetch( + api.getInscriptionOnSat(number, index), + InscriptionIDSchema, + ); + } + + /** + * Gets the first 100 inscription ids on a sat. Requires index with --index-sats flag. + * + * @param {number} number - Satoshi number + */ + async getInscriptionsOnSat(number: number): Promise { + return this.fetch( + api.getInscriptionsOnSat(number), + InscriptionsIDsResponseSchema, + ); + } + + /** + * Gets paginated inscription ids for a specific satoshi. + * + * @param {number} number - Satoshi number + * @param {number} page - Page number + */ + async getInscriptionsOnSatByPage( + number: number, + page: number, + ): Promise { + return this.fetch( + api.getInscriptionsOnSatByPage(number, page), + InscriptionsIDsResponseSchema, + ); + } + + /** + * Gets a specific child inscription of a parent inscription. + * + * @param {string} id - Parent inscription ID + * @param {number} child - Index of the child inscription + */ + async getChild(id: string, child: number): Promise { + return this.fetch(api.getChild(id, child), InscriptionInfoSchema); + } + + /** + * Gets first 100 child inscriptions IDs. + * + * @param {string} id - Parent inscription ID + */ + async getChildren(id: string): Promise { + return this.fetch(api.getChildren(id), InscriptionsIDsResponseSchema); + } + + /** + * Gets paginated child inscription IDs. + * + * @param {string} id - Parent inscription ID + * @param {number} page - Page number + */ + async getChildrenByPage( + id: string, + page: number, + ): Promise { + return this.fetch( + api.getChildrenByPage(id, page), + InscriptionsIDsResponseSchema, + ); + } + + /** + * Gets details of the first 100 child inscriptions. + * + * @param {string} id - Parent inscription ID + */ + async getChildrenInfo(id: string): Promise { + return this.fetch(api.getChildrenInfo(id), ChildrenInfoResponseSchema); + } + + /** + * Gets paginated detailed child inscription information. + * + * @param {string} id - Parent inscription ID + * @param {number} page - Page number + */ + async getChildrenInfoByPage( + id: string, + page: number, + ): Promise { + return this.fetch( + api.getChildrenInfoByPage(id, page), + ChildrenInfoResponseSchema, + ); + } + + /** + * Gets parent inscription IDs. + * + * @param {string} id - Child inscription ID + */ + async getParents(id: string): Promise { + return this.fetch(api.getParents(id), InscriptionsResponseSchema); + } + + /** + * Gets paginated parent inscription IDs. + * + * @param {string} id - Child inscription ID + * @param {number} page - Page number + */ + async getParentsByPage( + id: string, + page: number, + ): Promise { + return this.fetch( + api.getParentsByPage(id, page), InscriptionsResponseSchema, ); } @@ -258,24 +432,27 @@ export class OrdClient { * Retrieves information about a specific UTXO. * * @param {string} outpoint - Transaction outpoint in format {txid}:{vout} - * @returns {Promise} UTXO details */ async getOutput(outpoint: string): Promise { - return this.fetch(api.output(outpoint), OutputInfoSchema); + return this.fetch(api.getOutput(outpoint), OutputInfoSchema); + } + + /** + * Gets assets held by an UTXO. + * + * @param {string} outpoint - Transaction outpoint + */ + async getOutputAssets(outpoint: string): Promise { + return this.fetch(api.getOutputAssets(outpoint), OutputAssetsSchema); } /** * Gets information about multiple UTXOs. * * @param {string[]} outpoints - Array of outpoints to fetch - * @returns {Promise} Array of UTXO details */ async getOutputs(outpoints: string[]): Promise { - return this.fetchPost( - api.outputs.base, - outpoints, - z.array(OutputInfoSchema), - ); + return this.fetchPost(api.getOutputs, outpoints, z.array(OutputInfoSchema)); } /** @@ -283,14 +460,13 @@ export class OrdClient { * * @param {string} address - Bitcoin address to get outputs for * @param {OutputType} [type] - Optional filter for specific output types - * @returns {Promise} Array of address's UTXOs */ async getOutputsByAddress( address: string, type?: OutputType, ): Promise { return this.fetch( - api.outputs.byAddress(address, type), + api.getOutputsByAddress(address, type), z.array(OutputInfoSchema), ); } @@ -299,57 +475,58 @@ export class OrdClient { * Gets information about a specific rune by name. * * @param {string} name - Rune name - * @returns {Promise} Rune details */ async getRune(name: string): Promise { - return this.fetch(api.rune(name), RuneResponseSchema); + return this.fetch(api.getRune(name), RuneResponseSchema); } /** * Gets a list of the 100 most recent runes. - * - * @returns {Promise} Latest runes information */ - async getLatestRunes(): Promise { - return this.fetch(api.runes.latest, RunesResponseSchema); + async getRunesLatest(): Promise { + return this.fetch(api.getRunesLatest, RunesResponseSchema); } /** * Gets runes for a specific page number in paginated results. * * @param {number} page - Page number to fetch - * @returns {Promise} Page of runes */ async getRunesByPage(page: number): Promise { - return this.fetch(api.runes.byPage(page), RunesResponseSchema); + return this.fetch(api.getRunesByPage(page), RunesResponseSchema); } /** * Gets information about a specific satoshi by its number. * * @param {number} number - Satoshi number - * @returns {Promise} Satoshi details */ async getSat(number: number): Promise { - return this.fetch(api.sat(number), SatInfoSchema); + return this.fetch(api.getSat(number), SatInfoSchema); } /** * Gets information about a specific transaction. * * @param {string} txId - Transaction ID - * @returns {Promise} Transaction details */ async getTransaction(txId: string): Promise { - return this.fetch(api.tx(txId), TransactionInfoSchema); + return this.fetch(api.getTransaction(txId), TransactionInfoSchema); } /** - * Gets the current server status and information. + * Gets hex transaction data. * - * @returns {Promise} Server status details + * @param {string} txid - Transaction ID + */ + async getTransactionHex(txid: string): Promise { + return this.fetch(api.getTransactionHex(txid), TransactionHexSchema); + } + + /** + * Gets the current server status and information. */ async getServerStatus(): Promise { - return this.fetch(api.status, ServerStatusSchema); + return this.fetch(api.getServerStatus, ServerStatusSchema); } } diff --git a/src/schemas/block.ts b/src/schemas/block.ts index ddd17d9..1b67e10 100644 --- a/src/schemas/block.ts +++ b/src/schemas/block.ts @@ -26,3 +26,34 @@ export const BlocksResponseSchema = z.object({ blocks: z.array(BlockHashSchema), featured_blocks: z.record(BlockHashSchema, z.array(z.string())), }); + +export const BlockDetailsSchema = z.object({ + average_fee: z.number().int().nonnegative(), + average_fee_rate: z.number().nonnegative(), + bits: z.number().int().nonnegative(), + chainwork: z.string(), + confirmations: z.number().int().nonnegative(), + difficulty: z.number().nonnegative(), + hash: BlockHashSchema, + feerate_percentiles: z.array(z.number().nonnegative()), + height: z.number().int().nonnegative(), + max_fee: z.number().int().nonnegative(), + max_fee_rate: z.number().nonnegative(), + max_tx_size: z.number().int().nonnegative(), + median_fee: z.number().int().nonnegative(), + median_time: z.number().int().nonnegative().nullable(), + merkle_root: z.string(), + min_fee: z.number().int().nonnegative(), + min_fee_rate: z.number().nonnegative(), + next_block: BlockHashSchema.nullable(), + nonce: z.number().int().nonnegative(), + previous_block: BlockHashSchema.nullable(), + subsidy: z.number().int().nonnegative(), + target: z.string(), + timestamp: z.number().int(), + total_fee: z.number().int().nonnegative(), + total_size: z.number().int().nonnegative(), + total_weight: z.number().int().nonnegative(), + transaction_count: z.number().int().nonnegative(), + version: z.number().int(), +}); diff --git a/src/schemas/inscription.ts b/src/schemas/inscription.ts index c317073..1f50c2b 100644 --- a/src/schemas/inscription.ts +++ b/src/schemas/inscription.ts @@ -28,7 +28,7 @@ export const InscriptionInfoSchema = z.object({ fee: z.number().int().nonnegative(), height: z.number().int().nonnegative(), id: z.string(), - next: z.string().nullable().nullable(), + next: z.string().nullable(), number: z.number().int().nonnegative(), parents: z.array(z.string()), previous: z.string().nullable(), @@ -40,8 +40,53 @@ export const InscriptionInfoSchema = z.object({ metaprotocol: z.string().nullable(), }); +export const InscriptionRecursiveSchema = z.object({ + charms: z.array(CharmTypeSchema), + content_type: z.string().nullable(), + content_length: z.number().int().nonnegative().nullable(), + delegate: z.string().nullable(), + fee: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + id: z.string(), + number: z.number().int().nonnegative(), + output: z.string(), + sat: z.number().int().nonnegative().nullable(), + satpoint: z.string(), + timestamp: z.number().int(), + value: z.number().int().nonnegative().nullable(), + address: z.string().nullable(), +}); + export const InscriptionsResponseSchema = z.object({ ids: z.array(z.string()), more: z.boolean(), page_index: z.number().int().nonnegative(), }); + +export const InscriptionsIDsResponseSchema = z.object({ + ids: z.array(z.string()), + more: z.boolean(), + page: z.number().int().nonnegative(), +}); + +export const ChildInfoSchema = z.object({ + charms: z.array(CharmTypeSchema), + fee: z.number().int().nonnegative(), + height: z.number().int().nonnegative(), + id: z.string(), + number: z.number().int().nonnegative(), + output: z.string(), + sat: z.number().int().nonnegative(), + satpoint: z.string(), + timestamp: z.number().int(), +}); + +export const ChildrenInfoResponseSchema = z.object({ + children: z.array(ChildInfoSchema), + more: z.boolean(), + page: z.number().int().nonnegative(), +}); + +export const InscriptionIDSchema = z.object({ + id: z.string().nullable(), +}); diff --git a/src/schemas/output.ts b/src/schemas/output.ts index d7f2dc2..96abf72 100644 --- a/src/schemas/output.ts +++ b/src/schemas/output.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { RuneBalanceSchema } from './rune'; export const OutputTypeSchema = z.enum([ 'any', @@ -7,26 +8,27 @@ export const OutputTypeSchema = z.enum([ 'runic', ]); -const SatRangeSchema = z.tuple([ +export const SatRangeSchema = z.tuple([ z.number().int().nonnegative(), z.number().int().nonnegative(), ]); -const RuneInfoSchema = z.object({ - amount: z.number().int().nonnegative(), - divisibility: z.number().int().nonnegative(), - symbol: z.string(), -}); - export const OutputInfoSchema = z.object({ address: z.string().nullable(), indexed: z.boolean(), inscriptions: z.array(z.string()).nullable(), outpoint: z.string(), - runes: z.record(z.string(), RuneInfoSchema).nullable(), + runes: z.record(z.string(), RuneBalanceSchema).nullable(), sat_ranges: z.array(SatRangeSchema).nullable(), script_pubkey: z.string(), spent: z.boolean(), transaction: z.string(), value: z.number().int().nonnegative(), }); + +export const OutputAssetsSchema = z.object({ + inscriptions: z.array(z.string()).nullable(), + runes: z.record(z.string(), RuneBalanceSchema).nullable(), + sat_ranges: z.array(SatRangeSchema).nullable(), + value: z.number().int().nonnegative(), +}); diff --git a/src/schemas/rune.ts b/src/schemas/rune.ts index 97f5d02..92c25d4 100644 --- a/src/schemas/rune.ts +++ b/src/schemas/rune.ts @@ -9,6 +9,12 @@ export const RuneTermsSchema = z }) .nullable(); +export const RuneBalanceSchema = z.object({ + amount: z.number().int().nonnegative(), + divisibility: z.number().int().nonnegative(), + symbol: z.string(), +}); + export const RuneInfoSchema = z.object({ block: z.number().int().nonnegative(), burned: z.number().int().nonnegative(), diff --git a/src/schemas/transaction.ts b/src/schemas/transaction.ts index dccf0e6..a5c31c0 100644 --- a/src/schemas/transaction.ts +++ b/src/schemas/transaction.ts @@ -26,3 +26,10 @@ export const TransactionInfoSchema = z.object({ transaction: TransactionSchema, txid: z.string(), }); + +export const TransactionHexSchema = z + .string() + .regex( + /^[0-9a-f]+$/, + 'Transaction hex must contain only lowercase hexadecimal characters', + ); diff --git a/src/test/integration/api.test.ts b/src/test/integration/api.test.ts index e2cd42b..c2970a6 100644 --- a/src/test/integration/api.test.ts +++ b/src/test/integration/api.test.ts @@ -25,11 +25,11 @@ describe('API Integration Tests', () => { invalidClient = new OrdClient('https://invalid.api'); }); - describe('getBlock', () => { + describe('getBlockInfo', () => { test( 'fetches genesis block successfully', async () => { - const block = await client.getBlock(0); + const block = await client.getBlockInfo(0); expect(block.height).toBe(0); expect(block.hash).toBe(GENESIS_BLOCK.hash); }, @@ -39,7 +39,7 @@ describe('API Integration Tests', () => { test( 'rejects negative block height', async () => { - expect(client.getBlock(-1)).rejects.toThrow(); + expect(client.getBlockInfo(-1)).rejects.toThrow(); }, TIMEOUT, ); @@ -47,7 +47,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getBlock(0)).rejects.toThrow(); + expect(invalidClient.getBlockInfo(0)).rejects.toThrow(); }, TIMEOUT, ); @@ -132,7 +132,7 @@ describe('API Integration Tests', () => { test( 'returns valid hash successfully', async () => { - const hash = await client.getLatestBlockHash(); + const hash = await client.getBlockHash(); expect(hash).toMatch(/^[0-9a-f]{64}$/); }, TIMEOUT, @@ -141,7 +141,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestBlockHash()).rejects.toThrow(); + expect(invalidClient.getBlockHash()).rejects.toThrow(); }, TIMEOUT, ); @@ -151,7 +151,7 @@ describe('API Integration Tests', () => { test( 'returns height successfully', async () => { - const height = await client.getLatestBlockHeight(); + const height = await client.getBlockHeight(); expect(height).toBeGreaterThan(0); }, TIMEOUT, @@ -160,7 +160,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestBlockHeight()).rejects.toThrow(); + expect(invalidClient.getBlockHeight()).rejects.toThrow(); }, TIMEOUT, ); @@ -170,7 +170,7 @@ describe('API Integration Tests', () => { test( 'returns blocks list successfully', async () => { - const blocksResponse = await client.getLatestBlocks(); + const blocksResponse = await client.getBlocksLatest(); expect(typeof blocksResponse.last).toBe('number'); expect(Array.isArray(blocksResponse.blocks)).toBe(true); @@ -186,7 +186,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestBlocks()).rejects.toThrow(); + expect(invalidClient.getBlocksLatest()).rejects.toThrow(); }, TIMEOUT, ); @@ -196,7 +196,7 @@ describe('API Integration Tests', () => { test( 'returns timestamp successfully', async () => { - const time = await client.getLatestBlockTime(); + const time = await client.getBlockTime(); expect(time).toBeGreaterThan(0); }, TIMEOUT, @@ -205,7 +205,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestBlockTime()).rejects.toThrow(); + expect(invalidClient.getBlockTime()).rejects.toThrow(); }, TIMEOUT, ); @@ -215,7 +215,9 @@ describe('API Integration Tests', () => { test( 'fetches inscription successfully', async () => { - const inscription = await client.getInscription(SAMPLE_INSCRIPTION_ID); + const inscription = await client.getInscriptionInfo( + SAMPLE_INSCRIPTION_ID, + ); expect(inscription.id).toBe(SAMPLE_INSCRIPTION_ID); expect(inscription.address).toBeDefined(); expect(Array.isArray(inscription.charms)).toBe(true); @@ -228,7 +230,7 @@ describe('API Integration Tests', () => { 'handles server error', async () => { expect( - invalidClient.getInscription(SAMPLE_INSCRIPTION_ID), + invalidClient.getInscriptionInfo(SAMPLE_INSCRIPTION_ID), ).rejects.toThrow(); }, TIMEOUT, @@ -239,10 +241,7 @@ describe('API Integration Tests', () => { test( 'fetches child inscription successfully', async () => { - const child = await client.getInscriptionChild( - SAMPLE_INSCRIPTION_ID, - 0, - ); + const child = await client.getChild(SAMPLE_INSCRIPTION_ID, 0); expect(child.id).toBeDefined(); expect(Array.isArray(child.children)).toBe(true); expect(child.parents).toContain(SAMPLE_INSCRIPTION_ID); @@ -254,7 +253,7 @@ describe('API Integration Tests', () => { 'handles server error', async () => { expect( - invalidClient.getInscriptionChild(SAMPLE_INSCRIPTION_ID, 0), + invalidClient.getChild(SAMPLE_INSCRIPTION_ID, 0), ).rejects.toThrow(); }, TIMEOUT, @@ -265,7 +264,7 @@ describe('API Integration Tests', () => { test( 'fetches latest inscriptions successfully', async () => { - const response = await client.getLatestInscriptions(); + const response = await client.getInscriptions(); expect(Array.isArray(response.ids)).toBe(true); expect(response.ids.length).toBeGreaterThan(0); expect(typeof response.more).toBe('boolean'); @@ -277,7 +276,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestInscriptions()).rejects.toThrow(); + expect(invalidClient.getInscriptions()).rejects.toThrow(); }, TIMEOUT, ); @@ -503,7 +502,7 @@ describe('API Integration Tests', () => { test( 'fetches latest runes successfully', async () => { - const response = await client.getLatestRunes(); + const response = await client.getRunesLatest(); expect(Array.isArray(response.entries)).toBe(true); expect(typeof response.more).toBe('boolean'); if (response.entries.length > 0) { @@ -518,7 +517,7 @@ describe('API Integration Tests', () => { test( 'handles server error', async () => { - expect(invalidClient.getLatestRunes()).rejects.toThrow(); + expect(invalidClient.getRunesLatest()).rejects.toThrow(); }, TIMEOUT, ); diff --git a/src/test/integration/recursiveApi.test.ts b/src/test/integration/recursiveApi.test.ts new file mode 100644 index 0000000..4af54fc --- /dev/null +++ b/src/test/integration/recursiveApi.test.ts @@ -0,0 +1,442 @@ +import { expect, test, describe, beforeAll } from 'bun:test'; +import OrdClient from '../../index'; +import { BASE_URL, TIMEOUT } from '../config/test-config'; +import { + GENESIS_BLOCK, + SAMPLE_INSCRIPTION_ID, + SAMPLE_CHILD_ID, + SAMPLE_SAT_NUMBER, + SAMPLE_TX_ID, + SAMPLE_OUTPOINT_A, +} from '../data/test-data'; + +describe('Recursive API Integration Tests', () => { + let client: OrdClient; + let invalidClient: OrdClient; + + beforeAll(() => { + client = new OrdClient(BASE_URL); + invalidClient = new OrdClient('https://invalid.api'); + }); + + describe('getBlockHashRecursive', () => { + test( + 'returns latest block hash successfully', + async () => { + const hash = await client.getBlockHashRecursive(); + expect(hash).toMatch(/^[0-9a-f]{64}$/); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getBlockHashRecursive()).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getBlockHashByHeightRecursive', () => { + test( + 'returns genesis block hash successfully', + async () => { + const hash = await client.getBlockHashByHeightRecursive(0); + expect(hash).toBe(GENESIS_BLOCK.hash); + }, + TIMEOUT, + ); + + test( + 'rejects negative height', + async () => { + expect(client.getBlockHashByHeightRecursive(-1)).rejects.toThrow(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getBlockHashByHeightRecursive(0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getBlockHeightRecursive', () => { + test( + 'returns height successfully', + async () => { + const height = await client.getBlockHeightRecursive(); + expect(height).toBeGreaterThan(0); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getBlockHeightRecursive()).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getBlockTimeRecursive', () => { + test( + 'returns timestamp successfully', + async () => { + const time = await client.getBlockTimeRecursive(); + expect(time).toBeGreaterThan(0); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getBlockTimeRecursive()).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getBlockInfoRecursive', () => { + test( + 'fetches block info by height successfully', + async () => { + const block = await client.getBlockInfoRecursive(0); + expect(block.height).toBe(0); + expect(block.hash).toBe(GENESIS_BLOCK.hash); + expect(block.chainwork).toBeDefined(); + expect(block.bits).toBeDefined(); + expect(block.merkle_root).toBeDefined(); + }, + TIMEOUT, + ); + + test( + 'fetches block info by hash successfully', + async () => { + const block = await client.getBlockInfoRecursive(GENESIS_BLOCK.hash); + expect(block.height).toBe(0); + expect(block.hash).toBe(GENESIS_BLOCK.hash); + expect(block.chainwork).toBeDefined(); + expect(block.bits).toBeDefined(); + expect(block.merkle_root).toBeDefined(); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getBlockInfoRecursive(0)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionRecursive', () => { + test( + 'fetches inscription info successfully', + async () => { + const inscription = await client.getInscriptionRecursive( + SAMPLE_INSCRIPTION_ID, + ); + expect(inscription.id).toBe(SAMPLE_INSCRIPTION_ID); + expect(inscription.content_length).toBeGreaterThan(0); + expect(inscription.height).toBeGreaterThan(0); + expect(Array.isArray(inscription.charms)).toBe(true); + expect(typeof inscription.satpoint).toBe('string'); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getInscriptionRecursive(SAMPLE_INSCRIPTION_ID), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getChildren', () => { + test( + 'fetches children ids successfully', + async () => { + const children = await client.getChildren(SAMPLE_INSCRIPTION_ID); + expect(Array.isArray(children.ids)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getChildren(SAMPLE_INSCRIPTION_ID), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getChildrenByPage', () => { + test( + 'fetches paginated children ids successfully', + async () => { + const children = await client.getChildrenByPage( + SAMPLE_INSCRIPTION_ID, + 0, + ); + expect(Array.isArray(children.ids)).toBe(true); + expect(children.page).toBe(0); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getChildrenByPage(SAMPLE_INSCRIPTION_ID, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getChildrenInfo', () => { + test( + 'fetches children info successfully', + async () => { + const children = await client.getChildrenInfo(SAMPLE_INSCRIPTION_ID); + expect(typeof children.more).toBe('boolean'); + expect(typeof children.page).toBe('number'); + expect(children.page).toBeGreaterThanOrEqual(0); + expect(Array.isArray(children.children)).toBe(true); + if (children.children.length > 0) { + const child = children.children[0]; + expect(Array.isArray(child.charms)).toBe(true); + expect(typeof child.fee).toBe('number'); + expect(typeof child.height).toBe('number'); + expect(typeof child.id).toBe('string'); + expect(typeof child.number).toBe('number'); + expect(typeof child.output).toBe('string'); + expect(typeof child.sat).toBe('number'); + expect(typeof child.satpoint).toBe('string'); + expect(typeof child.timestamp).toBe('number'); + } + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getChildrenInfo(SAMPLE_INSCRIPTION_ID), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getChildrenInfoByPage', () => { + test( + 'fetches paginated children info successfully', + async () => { + const children = await client.getChildrenInfoByPage( + SAMPLE_INSCRIPTION_ID, + 0, + ); + expect(typeof children.more).toBe('boolean'); + expect(typeof children.page).toBe('number'); + expect(children.page).toBeGreaterThanOrEqual(0); + expect(Array.isArray(children.children)).toBe(true); + if (children.children.length > 0) { + const child = children.children[0]; + expect(Array.isArray(child.charms)).toBe(true); + expect(typeof child.fee).toBe('number'); + expect(typeof child.height).toBe('number'); + expect(typeof child.id).toBe('string'); + expect(typeof child.number).toBe('number'); + expect(typeof child.output).toBe('string'); + expect(typeof child.sat).toBe('number'); + expect(typeof child.satpoint).toBe('string'); + expect(typeof child.timestamp).toBe('number'); + } + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getChildrenInfoByPage(SAMPLE_INSCRIPTION_ID, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getParents', () => { + test( + 'fetches parent ids successfully', + async () => { + const parents = await client.getParents(SAMPLE_CHILD_ID); + expect(Array.isArray(parents.ids)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getParents(SAMPLE_CHILD_ID)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getParentsByPage', () => { + test( + 'fetches paginated parent ids successfully', + async () => { + const parents = await client.getParentsByPage(SAMPLE_CHILD_ID, 0); + expect(Array.isArray(parents.ids)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getParentsByPage(SAMPLE_CHILD_ID, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionOnSat', () => { + test( + 'fetches inscription at index successfully', + async () => { + const inscription = await client.getInscriptionOnSat( + SAMPLE_SAT_NUMBER, + 0, + ); + expect(inscription.id).toBe(null); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getInscriptionOnSat(SAMPLE_SAT_NUMBER, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionsOnSat', () => { + test( + 'fetches inscriptions successfully', + async () => { + const inscriptions = + await client.getInscriptionsOnSat(SAMPLE_SAT_NUMBER); + expect(Array.isArray(inscriptions.ids)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getInscriptionsOnSat(SAMPLE_SAT_NUMBER), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getInscriptionsOnSatByPage', () => { + test( + 'fetches paginated inscriptions successfully', + async () => { + const inscriptions = await client.getInscriptionsOnSatByPage( + SAMPLE_SAT_NUMBER, + 0, + ); + expect(Array.isArray(inscriptions.ids)).toBe(true); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getInscriptionsOnSatByPage(SAMPLE_SAT_NUMBER, 0), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getOutputAssets', () => { + test( + 'fetches output info successfully', + async () => { + const output = await client.getOutputAssets(SAMPLE_OUTPOINT_A); + expect(output.value).toBeGreaterThanOrEqual(0); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect( + invalidClient.getOutputAssets(SAMPLE_OUTPOINT_A), + ).rejects.toThrow(); + }, + TIMEOUT, + ); + }); + + describe('getTransactionHex', () => { + test( + 'fetches transaction hex successfully', + async () => { + const hex = await client.getTransactionHex(SAMPLE_TX_ID); + expect(typeof hex).toBe('string'); + expect(hex).toMatch(/^[0-9a-f]+$/); + }, + TIMEOUT, + ); + + test( + 'handles server error', + async () => { + expect(invalidClient.getTransactionHex(SAMPLE_TX_ID)).rejects.toThrow(); + }, + TIMEOUT, + ); + }); +}); diff --git a/src/test/unit/schemas.test.ts b/src/test/unit/schemas.test.ts index 91a1378..3bdd3a0 100644 --- a/src/test/unit/schemas.test.ts +++ b/src/test/unit/schemas.test.ts @@ -8,14 +8,19 @@ import { AddressInfoSchema } from '../../schemas/address'; import { InputSchema, OutputSchema, + TransactionHexSchema, TransactionSchema, } from '../../schemas/transaction'; import { + ChildInfoSchema, + ChildrenInfoResponseSchema, + InscriptionIDSchema, InscriptionInfoSchema, + InscriptionRecursiveSchema, InscriptionsResponseSchema, } from '../../schemas/inscription'; -import { OutputInfoSchema } from '../../schemas/output'; -import { RuneInfoSchema, RunesResponseSchema } from '../../schemas/rune'; +import { OutputAssetsSchema, OutputInfoSchema, SatRangeSchema } from '../../schemas/output'; +import { RuneBalanceSchema, RuneInfoSchema, RunesResponseSchema } from '../../schemas/rune'; import { SatInfoSchema } from '../../schemas/sat'; import { ServerStatusSchema } from '../../schemas/status'; import { @@ -25,7 +30,6 @@ import { SAMPLE_TRANSACTION, SAMPLE_INPUT, SAMPLE_OUTPUT, - SAMPLE_RUNE_BALANCE, SAMPLE_INSCRIPTION, SAMPLE_INSCRIPTIONS_RESPONSE, SAMPLE_UTXO_INFO, @@ -97,6 +101,68 @@ describe('Schema Validation', () => { expect(TransactionSchema.safeParse(invalidTx).success).toBe(false); }); }); + + describe('TransactionHexSchema', () => { + test('validates valid hex string', () => { + expect(TransactionHexSchema.safeParse("0123456789abcdef").success).toBe(true); + }); + + test('rejects non-hex characters', () => { + expect(TransactionHexSchema.safeParse("0123456789abcdefg").success).toBe(false); + }); + + test('rejects uppercase hex', () => { + expect(TransactionHexSchema.safeParse("0123456789ABCDEF").success).toBe(false); + }); + }); + + describe('OutputAssetsSchema', () => { + test('validates valid output assets', () => { + const validAssets = { + inscriptions: [], + runes: {}, + sat_ranges: [], + value: 1000 + }; + expect(OutputAssetsSchema.safeParse(validAssets).success).toBe(true); + }); + + test('validates null fields', () => { + const validAssets = { + inscriptions: null, + runes: null, + sat_ranges: null, + value: 1000 + }; + expect(OutputAssetsSchema.safeParse(validAssets).success).toBe(true); + }); + + test('rejects negative value', () => { + const invalidAssets = { + inscriptions: [], + runes: {}, + sat_ranges: [], + value: -1000 + }; + expect(OutputAssetsSchema.safeParse(invalidAssets).success).toBe(false); + }); + }); + + describe('SatRangeSchema', () => { + test('validates valid sat range', () => { + expect(SatRangeSchema.safeParse([0, 1000]).success).toBe(true); + }); + + test('rejects negative numbers', () => { + expect(SatRangeSchema.safeParse([-1, 1000]).success).toBe(false); + expect(SatRangeSchema.safeParse([0, -1000]).success).toBe(false); + }); + + test('rejects wrong tuple length', () => { + expect(SatRangeSchema.safeParse([0]).success).toBe(false); + expect(SatRangeSchema.safeParse([0, 1000, 2000]).success).toBe(false); + }); + }); }); describe('Block Schemas', () => { @@ -227,7 +293,9 @@ describe('Schema Validation', () => { ...SAMPLE_INSCRIPTION, charms: ['invalid_charm'], }; - const result = InscriptionInfoSchema.safeParse(inscriptionWithInvalidCharm); + const result = InscriptionInfoSchema.safeParse( + inscriptionWithInvalidCharm, + ); expect(result.success).toBe(false); }); @@ -238,7 +306,9 @@ describe('Schema Validation', () => { children: [], parents: [], }; - const result = InscriptionInfoSchema.safeParse(inscriptionWithEmptyArrays); + const result = InscriptionInfoSchema.safeParse( + inscriptionWithEmptyArrays, + ); expect(result.success).toBe(true); }); @@ -263,7 +333,9 @@ describe('Schema Validation', () => { fee: -1, value: -1, }; - const result = InscriptionInfoSchema.safeParse(inscriptionWithNegatives); + const result = InscriptionInfoSchema.safeParse( + inscriptionWithNegatives, + ); expect(result.success).toBe(false); }); }); @@ -286,6 +358,114 @@ describe('Schema Validation', () => { expect(result.success).toBe(false); }); }); + + describe('InscriptionRecursiveSchema', () => { + test('validates valid recursive inscription', () => { + const validInscription = { + charms: ["rare", "uncommon"], + content_type: "text/plain", + content_length: 100, + delegate: null, + fee: 1000, + height: 1000, + id: "abc123", + number: 1, + output: "txid:0", + sat: 1000, + satpoint: "txid:0:0", + timestamp: 1234567890, + value: 1000, + address: "bc1..." + }; + expect(InscriptionRecursiveSchema.safeParse(validInscription).success).toBe(true); + }); + + test('validates null fields', () => { + const validInscription = { + charms: [], + content_type: null, + content_length: null, + delegate: null, + fee: 1000, + height: 1000, + id: "abc123", + number: 1, + output: "txid:0", + sat: null, + satpoint: "txid:0:0", + timestamp: 1234567890, + value: null, + address: null + }; + expect(InscriptionRecursiveSchema.safeParse(validInscription).success).toBe(true); + }); + }); + + describe('ChildInfoSchema', () => { + test('validates valid child info', () => { + const validChild = { + charms: ["rare"], + fee: 1000, + height: 1000, + id: "abc123", + number: 1, + output: "txid:0", + sat: 1000, + satpoint: "txid:0:0", + timestamp: 1234567890 + }; + expect(ChildInfoSchema.safeParse(validChild).success).toBe(true); + }); + + test('rejects negative numbers', () => { + const invalidChild = { + charms: ["rare"], + fee: -1000, + height: 1000, + id: "abc123", + number: 1, + output: "txid:0", + sat: 1000, + satpoint: "txid:0:0", + timestamp: 1234567890 + }; + expect(ChildInfoSchema.safeParse(invalidChild).success).toBe(false); + }); + }); + + describe('ChildrenInfoResponseSchema', () => { + test('validates valid children info response', () => { + const validResponse = { + children: [], + more: false, + page: 0 + }; + expect(ChildrenInfoResponseSchema.safeParse(validResponse).success).toBe(true); + }); + + test('rejects negative page number', () => { + const invalidResponse = { + children: [], + more: false, + page: -1 + }; + expect(ChildrenInfoResponseSchema.safeParse(invalidResponse).success).toBe(false); + }); + }); + + describe('InscriptionIDSchema', () => { + test('validates valid inscription ID', () => { + expect(InscriptionIDSchema.safeParse({ id: "abc123" }).success).toBe(true); + }); + + test('validates null ID', () => { + expect(InscriptionIDSchema.safeParse({ id: null }).success).toBe(true); + }); + + test('rejects missing ID field', () => { + expect(InscriptionIDSchema.safeParse({}).success).toBe(false); + }); + }); }); describe('Output Info Schemas', () => { @@ -338,6 +518,43 @@ describe('Schema Validation', () => { }); describe('Rune Schemas', () => { + describe('RuneBalanceSchema', () => { + test('validates valid rune balance', () => { + const validBalance = { + amount: 1000, + divisibility: 8, + symbol: "TEST•RUNE" + }; + expect(RuneBalanceSchema.safeParse(validBalance).success).toBe(true); + }); + + test('rejects negative amount', () => { + const invalidBalance = { + amount: -1000, + divisibility: 8, + symbol: "TEST•RUNE" + }; + expect(RuneBalanceSchema.safeParse(invalidBalance).success).toBe(false); + }); + + test('rejects negative divisibility', () => { + const invalidBalance = { + amount: 1000, + divisibility: -8, + symbol: "TEST•RUNE" + }; + expect(RuneBalanceSchema.safeParse(invalidBalance).success).toBe(false); + }); + + test('rejects missing required fields', () => { + const invalidBalance = { + amount: 1000, + symbol: "TEST•RUNE" + }; + expect(RuneBalanceSchema.safeParse(invalidBalance).success).toBe(false); + }); + }); + describe('RuneInfoSchema', () => { test('validates valid rune', () => { const result = RuneInfoSchema.safeParse(SAMPLE_RUNE); diff --git a/src/types/index.ts b/src/types/index.ts index 26f6faa..4520b80 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -11,6 +11,7 @@ import type { InscriptionsResponseSchema, OutputInfoSchema, RuneInfoSchema, + RuneBalanceSchema, RunesResponseSchema, SatInfoSchema, ServerStatusSchema, @@ -19,19 +20,30 @@ import type { RarityTypeSchema, OutputSchema, OutputTypeSchema, + BlockDetailsSchema, + InscriptionIDSchema, + InscriptionsIDsResponseSchema, + ChildrenInfoResponseSchema, + InscriptionRecursiveSchema, + OutputAssetsSchema, + TransactionHexSchema, + SatRangeSchema, + ChildInfoSchema, } from '../schemas'; /** * Comprehensive information about a Bitcoin address including its balance, outputs, inscriptions, and runes balances. - * */ export type AddressInfo = z.infer; /** - * Detailed block information including height, hash, timestamp, transaction list, and inscription data. - * + * Basic block information including inscriptions and runes. */ export type BlockInfo = z.infer; +/** + * Detailed information about given block. + */ +export type BlockDetails = z.infer; /** * A Bitcoin block hash represented as a hex string. */ @@ -49,6 +61,10 @@ export type Transaction = z.infer; * Extended transaction information including block details, timestamp and inscription data. */ export type TransactionInfo = z.infer; +/** + * Hex-encoded transaction. + */ +export type TransactionHex = z.infer; /** * Transaction input containing previous output reference and witness data. */ @@ -57,12 +73,14 @@ export type Input = z.infer; * Transaction output containing value and script pubkey. */ export type Output = z.infer; - /** - * Information about a specific satoshi including its number, timestamp, - * and rarity classification. + * Information about a specific satoshi including its number, timestamp, and rarity classification. */ export type SatInfo = z.infer; +/** + * A tuple representing a range of satoshis with start and end values. + */ +export type SatRange = z.infer; /** * Special characteristics or properties of a sat (e.g. "cursed", "epic", "burned"). */ @@ -77,23 +95,51 @@ export type RarityType = z.infer; export type OutputType = z.infer; /** * Detailed information about a UTXO including value, script type, and any inscriptions or runes it contains. - * */ export type OutputInfo = z.infer; +/** + * Information about assets held by an UTXO. + */ +export type OutputAssets = z.infer; /** * Comprehensive information about an inscription including its content type, genesis data, location and transfer history. - * */ export type InscriptionInfo = z.infer; /** - * Paginated response containing a list of inscriptions and metadata. + * Comprehensive information about an inscription retrieved from recursive endpoint. + */ +export type InscriptionRecursive = z.infer; +/** + * Paginated response containing a list of inscriptions IDs. */ export type InscriptionsResponse = z.infer; +/** + * Response containing a single inscription ID. + */ +export type InscriptionID = z.infer; +/** + * Paginated response containing a list of inscription IDs + */ +export type InscriptionsIDsResponse = z.infer< + typeof InscriptionsIDsResponseSchema +>; +/** + * Paginated response containing child inscriptions detailed info. + */ +export type ChildrenInfoResponse = z.infer; +/** + * Child inscription info retrieved from recursive endpoint. + */ +export type ChildInfo = z.infer; /** * Basic information about a rune including its symbol and supply details. */ export type RuneInfo = z.infer; +/** + * Basic information about a rune held by an UTXO. + */ +export type RuneBalance = z.infer; /** * Detailed rune information including minting status and parent. */ @@ -105,6 +151,5 @@ export type RunesResponse = z.infer; /** * Current status information about the ordinals server including version, height and indexing progress. - * */ export type ServerStatus = z.infer;