Skip to main content
Glama
WorkspaceSchemaService.ts12.1 kB
/** * Service for fetching and aggregating Attio workspace schema data * * This service orchestrates calls to existing services to build a complete * workspace schema including objects, attributes, select options, and metadata. * * @see Issue #983 */ import { getObjectAttributeMetadata } from '@/api/attribute-types.js'; import { getLazyAttioClient } from '@/api/lazy-client.js'; import { AttributeOptionsService } from '@/services/metadata/AttributeOptionsService.js'; import { debug as logDebug, error as logError, warn as logWarn, } from '@/utils/logger.js'; import type { WorkspaceSchema, ObjectSchema, AttributeSchema, FetchSchemaOptions, } from './types.js'; /** * Represents a nested option ID object returned by Attio API * @see Issue #1014 */ interface NestedOptionId { option_id: string; workspace_id?: string; object_id?: string; attribute_id?: string; } /** * Type guard to check if an ID is a nested option ID object * Attio API returns option IDs as nested objects: {workspace_id, object_id, attribute_id, option_id} * @param id - The ID value to check * @returns True if the ID is a nested option ID object * @see Issue #1014 */ function isNestedOptionId(id: unknown): id is NestedOptionId { return typeof id === 'object' && id !== null && 'option_id' in id; } /** * Generates a slug-style value from an option title * Converts "Existing Customer" → "existing_customer" * @param title - The option title * @returns Slug-style value * @see Issue #1014 */ function generateOptionValue(title: string): string { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '_') // Replace non-alphanumeric with underscore .replace(/^_+|_+$/g, ''); // Trim leading/trailing underscores } /** * Service for fetching complete workspace schema data * * Note: API key flows through getLazyAttioClient() from environment/context, * not through constructor dependency injection. */ export class WorkspaceSchemaService { /** * Creates a new WorkspaceSchemaService */ constructor() { // No parameters needed - API key flows through getLazyAttioClient() } /** * Fetches the display title for an object from the Attio API * * @param objectSlug - Object API slug (e.g., 'companies', 'custom_prospecting_list') * @returns The object title from Attio, or null if fetch fails * @see Issue #1017 */ private async fetchObjectTitle(objectSlug: string): Promise<string | null> { try { const client = getLazyAttioClient(); const response = await client.get(`/objects/${objectSlug}`); const obj = response?.data?.data || response?.data; return obj?.title || null; } catch { logDebug( 'WorkspaceSchemaService', `Could not fetch title for ${objectSlug}, using fallback`, { objectSlug } ); return null; } } private getOptionFetchDelayMs(options: FetchSchemaOptions): number { const optionFetchDelayMs = options.optionFetchDelayMs; if (optionFetchDelayMs === undefined) return 100; if ( typeof optionFetchDelayMs !== 'number' || !Number.isFinite(optionFetchDelayMs) || optionFetchDelayMs < 0 ) { return 100; } return optionFetchDelayMs; } /** * Fetches complete workspace schema for specified objects * * This method implements graceful degradation: * - If an object fails to fetch, it logs the error and continues with other objects * - If an attribute's options fail to fetch, it includes the attribute without options * * @param objectSlugs - Array of object slugs to fetch (e.g., ['companies', 'people']) * @param options - Fetching options (max options, include archived) * @returns Complete workspace schema */ async fetchSchema( objectSlugs: string[], options: FetchSchemaOptions ): Promise<WorkspaceSchema> { const objects: ObjectSchema[] = []; for (const objectSlug of objectSlugs) { try { const objectSchema = await this.fetchObjectSchema(objectSlug, options); objects.push(objectSchema); } catch (error: unknown) { logError( 'WorkspaceSchemaService', `Failed to fetch schema for ${objectSlug}`, error instanceof Error ? error : new Error(String(error)), { objectSlug } ); // Continue processing other objects despite error } } return { metadata: { generatedAt: new Date().toISOString(), workspace: 'attio', // Could be enhanced to fetch actual workspace name objects: objectSlugs, }, objects, }; } /** * Fetches schema for a single object * * @param objectSlug - Object slug (e.g., 'companies', 'people', 'deals') * @param options - Fetching options * @returns Object schema with all attributes and metadata */ private async fetchObjectSchema( objectSlug: string, options: FetchSchemaOptions ): Promise<ObjectSchema> { const optionFetchDelayMs = this.getOptionFetchDelayMs(options); const PHASE1_OBJECTS = ['companies', 'people', 'deals']; // 1. Fetch object title from API for custom objects (Issue #1017) // Skip API call for Phase 1 objects - they have hardcoded display names const objectTitle = PHASE1_OBJECTS.includes(objectSlug) ? null : await this.fetchObjectTitle(objectSlug); // 2. Fetch attribute metadata (uses existing 15min TTL cache) const metadataMap = await getObjectAttributeMetadata(objectSlug); // 3. Convert metadata to AttributeSchema array const attributes: AttributeSchema[] = []; for (const [apiSlug, metadata] of metadataMap.entries()) { const attributeSchema: AttributeSchema = { apiSlug, displayName: metadata.title, type: metadata.type, isMultiselect: metadata.is_multiselect || false, isUnique: metadata.is_unique || false, isRequired: metadata.is_required || false, isWritable: metadata.is_writable !== false, // Default to true description: metadata.description, }; // 4. Fetch options for select/status attributes if (this.isOptionBasedAttribute(metadata.type)) { try { const optionsResult = await AttributeOptionsService.getOptions( objectSlug, apiSlug, options.includeArchived ); // Apply truncation const totalOptions = optionsResult.options.length; const truncated = totalOptions > options.maxOptionsPerAttribute; attributeSchema.options = optionsResult.options .slice(0, options.maxOptionsPerAttribute) .map((opt) => ({ // Handle nested ID objects from Attio API // API returns: { workspace_id, object_id, attribute_id, option_id } id: isNestedOptionId(opt.id) ? opt.id.option_id : typeof opt.id === 'string' ? opt.id : '', title: opt.title, // Generate slug-style value from title since Attio API doesn't provide it // "Existing Customer" → "existing_customer" value: generateOptionValue(opt.title), isArchived: 'is_archived' in opt ? opt.is_archived : false, })); attributeSchema.optionsTruncated = truncated; attributeSchema.totalOptions = totalOptions; // Rate limiting: Add delay between option fetches if (optionFetchDelayMs > 0) { await new Promise((resolve) => setTimeout(resolve, optionFetchDelayMs) ); } } catch (error: unknown) { // Log warning but don't fail - attribute can still be documented const errorMessage = error instanceof Error ? error.message : String(error); logWarn( 'WorkspaceSchemaService', `No options available for ${objectSlug}.${apiSlug}`, { objectSlug, attributeSlug: apiSlug, errorMessage } ); } } // 5. Add complex type structures if (this.isComplexType(metadata.type)) { attributeSchema.complexTypeStructure = this.getComplexTypeStructure( metadata.type ); } // 6. Add relationship metadata (only when we have real data) if (metadata.relationship?.object && metadata.relationship?.cardinality) { attributeSchema.relationship = { targetObject: metadata.relationship.object, cardinality: metadata.relationship.cardinality, }; } attributes.push(attributeSchema); } return { objectSlug, displayName: this.getDisplayName(objectSlug, objectTitle), attributes, }; } /** * Checks if an attribute type supports options (select, status, multi-select) * * @param type - Attribute type from Attio API * @returns True if attribute supports options */ private isOptionBasedAttribute(type: string): boolean { return ['select', 'status', 'multi-select'].includes(type); } /** * Checks if an attribute type is a complex type requiring structure documentation * * @param type - Attribute type from Attio API * @returns True if attribute is a complex type */ private isComplexType(type: string): boolean { return [ 'location', 'personal-name', 'phone-number', 'email-address', ].includes(type); } /** * Gets the structure definition for complex attribute types * * @param type - Complex attribute type * @returns Structure definition with field types */ private getComplexTypeStructure(type: string): Record<string, unknown> { switch (type) { case 'location': return { line_1: 'string | null (street address)', line_2: 'string | null (apt/suite)', line_3: 'string | null (additional)', line_4: 'string | null (additional)', locality: 'string | null (city)', region: 'string | null (state/province)', postcode: 'string | null (ZIP/postal code)', country_code: 'string | null (ISO country code)', latitude: 'number | null (coordinates)', longitude: 'number | null (coordinates)', }; case 'personal-name': return { first_name: 'string (required)', last_name: 'string | null', middle_name: 'string | null', title: 'string | null (e.g., "Dr.", "Prof.")', full_name: 'string (auto-generated, read-only)', }; case 'phone-number': return { country_code: 'string (e.g., "+1")', number: 'string (digits only)', original_number: 'string (as provided)', }; case 'email-address': return { email_address: 'string (valid email format)', }; default: return {}; } } /** * Gets human-readable display name for an object slug * * Uses the fetched title from Attio API if available, otherwise falls back * to hardcoded display names for Phase 1 objects or slug capitalization. * * @param objectSlug - Object API slug * @param fetchedTitle - Title fetched from Attio API (optional) * @returns Human-readable display name * @see Issue #1017 */ private getDisplayName( objectSlug: string, fetchedTitle?: string | null ): string { // Use API-fetched title if available if (fetchedTitle) { return fetchedTitle; } // Fallback to hardcoded display names for Phase 1 objects const displayNames: Record<string, string> = { companies: 'Companies', people: 'People', deals: 'Deals', }; // If not in map, title-case the slug (replace underscores with spaces) return ( displayNames[objectSlug] || objectSlug .split('_') .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) .join(' ') ); } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/kesslerio/attio-mcp-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server