/ scripts / gen-openapi.ts
gen-openapi.ts
  1  import { writeFileSync } from 'fs';
  2  import https from 'https';
  3  import http from 'http';
  4  import { execSync } from 'child_process';
  5  import fs from 'fs';
  6  import path from 'path';
  7  
  8  const env = process.argv[2] ?? 'dev';
  9  const targets: Record<string, string> = {
 10      dev: 'http://localhost:8000/openapi.json',
 11      preview: 'https://api-preview.cognidao.org/openapi.json',
 12      prod: 'https://api.cognidao.org/openapi.json'
 13  };
 14  
 15  // Enhanced fetch function that follows redirects
 16  function fetchData(url: string, maxRedirects: number = 5): Promise<string> {
 17      return new Promise((resolve, reject) => {
 18          const makeRequest = (currentUrl: string, redirectsLeft: number) => {
 19              const client = currentUrl.startsWith('https') ? https : http;
 20  
 21              console.log(`   Fetching: ${currentUrl}`);
 22              client.get(currentUrl, (res) => {
 23                  if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
 24                      // Got a redirect
 25                      if (redirectsLeft === 0) {
 26                          reject(new Error(`Too many redirects (followed ${maxRedirects})`));
 27                          return;
 28                      }
 29  
 30                      // Construct absolute URL if relative
 31                      const redirectUrl = /^https?:\/\//.test(res.headers.location)
 32                          ? res.headers.location
 33                          : new URL(res.headers.location, new URL(currentUrl).origin).toString();
 34  
 35                      console.log(`   Redirected to: ${redirectUrl}`);
 36                      makeRequest(redirectUrl, redirectsLeft - 1);
 37                      return;
 38                  }
 39  
 40              if (res.statusCode !== 200) {
 41                  reject(new Error(`Failed to fetch: ${res.statusCode}`));
 42                  return;
 43              }
 44  
 45              let data = '';
 46              res.on('data', (chunk) => {
 47                  data += chunk;
 48              });
 49  
 50              res.on('end', () => {
 51                  resolve(data);
 52              });
 53          }).on('error', (err) => {
 54              reject(err);
 55          });
 56          };
 57  
 58          makeRequest(url, maxRedirects);
 59      });
 60  }
 61  
 62  // Type definitions for schema handling
 63  interface JsonSchema {
 64      properties?: Record<string, any>;
 65      required?: string[];
 66      description?: string;
 67      [key: string]: any;
 68  }
 69  
 70  interface SchemaInfo {
 71      type: string;
 72      version: number | string;
 73      latest_url?: string;
 74      [key: string]: any;
 75  }
 76  
 77  // Generate combined metadata file with both Zod schema and interface
 78  function generateCombinedMetadataFile(schema: JsonSchema, typeName: string): string {
 79      // Generate properties for both Zod schema and TypeScript interface
 80      const requiredProps = schema.required || [];
 81  
 82      const zodProperties = Object.entries(schema.properties || {}).map(([propName, propSchema]) => {
 83          const required = requiredProps.includes(propName);
 84          let zodType = 'z.unknown()';
 85  
 86          // Handle different property types
 87          if (propSchema.type === 'string') {
 88              zodType = 'z.string()';
 89              if (propSchema.format === 'date-time') {
 90                  zodType = 'z.string().datetime()';
 91              } else if (propSchema.enum) {
 92                  zodType = `z.enum([${propSchema.enum.map(e => `'${e}'`).join(', ')}])`;
 93              }
 94          } else if (propSchema.type === 'number') {
 95              zodType = 'z.number()';
 96              if (propSchema.minimum !== undefined) {
 97                  zodType = `${zodType}.min(${propSchema.minimum})`;
 98              }
 99              if (propSchema.maximum !== undefined) {
100                  zodType = `${zodType}.max(${propSchema.maximum})`;
101              }
102          } else if (propSchema.type === 'boolean') {
103              zodType = 'z.boolean()';
104          } else if (propSchema.type === 'array') {
105              zodType = 'z.array(z.unknown())';
106          } else if (propSchema.type === 'object') {
107              zodType = 'z.record(z.string(), z.unknown())';
108          } else if (propSchema.anyOf || propSchema.oneOf) {
109              // Handle union types
110              const types = (propSchema.anyOf || propSchema.oneOf).map((s: any) => {
111                  if (s.type === 'null') return 'z.null()';
112                  if (s.type === 'string') return 'z.string()';
113                  if (s.type === 'number') return 'z.number()';
114                  if (s.type === 'boolean') return 'z.boolean()';
115                  return 'z.unknown()';
116              }).join(', ');
117              zodType = `z.union([${types}])`;
118          }
119  
120          return `  ${propName}: ${zodType}${required ? '' : '.optional()'}`;
121      });
122  
123      const tsProperties = Object.entries(schema.properties || {}).map(([propName, propSchema]) => {
124          const required = requiredProps.includes(propName);
125          let tsType = 'unknown';
126  
127          // Handle different property types
128          if (propSchema.type === 'string') {
129              tsType = 'string';
130              if (propSchema.enum) {
131                  tsType = propSchema.enum.map((e: string) => `'${e}'`).join(' | ');
132              }
133          } else if (propSchema.type === 'number') {
134              tsType = 'number';
135          } else if (propSchema.type === 'boolean') {
136              tsType = 'boolean';
137          } else if (propSchema.type === 'array') {
138              tsType = 'unknown[]';
139          } else if (propSchema.type === 'object') {
140              tsType = 'Record<string, unknown>';
141          } else if (propSchema.anyOf || propSchema.oneOf) {
142              // Handle union types
143              const types = (propSchema.anyOf || propSchema.oneOf).map((s: any) => {
144                  if (s.type === 'null') return 'null';
145                  if (s.type === 'string') return 'string';
146                  if (s.type === 'number') return 'number';
147                  if (s.type === 'boolean') return 'boolean';
148                  return 'unknown';
149              }).join(' | ');
150              tsType = types;
151          }
152  
153          return `  ${propName}${required ? '' : '?'}: ${tsType};`;
154      });
155  
156      return `import { z } from 'zod';
157  
158  /**
159   * ${schema.description || `Metadata schema for ${typeName}`}
160   */
161  export const ${typeName}Schema = z.object({
162  ${zodProperties.join(',\n')}
163  });
164  
165  /**
166   * ${schema.description || `TypeScript interface for ${typeName}`}
167   * Manually defined to match Zod schema exactly (no z.infer<>)
168   */
169  export interface ${typeName} {
170  ${tsProperties.join('\n')}
171  }
172  `;
173  }
174  
175  // Main function to generate metadata models
176  async function generateMetadataModels(baseUrl: string): Promise<void> {
177      console.log('🧩 Generating metadata models...');
178  
179      try {
180          const outputDir = path.resolve(process.cwd(), 'src', 'data', 'block_metadata');
181  
182          // Ensure the output directory exists
183          if (!fs.existsSync(outputDir)) {
184              console.log(`Creating output directory: ${outputDir}`);
185              fs.mkdirSync(outputDir, { recursive: true });
186          }
187  
188          // Fetch schema index with the new API v1 path
189          const schemaIndexUrl = `${baseUrl}/api/v1/schemas/index.json`;
190          console.log(`Fetching schema index from: ${schemaIndexUrl}`);
191  
192          const indexContent = await fetchData(schemaIndexUrl);
193          const schemaIndex = JSON.parse(indexContent).schemas as SchemaInfo[];
194  
195          if (!Array.isArray(schemaIndex)) {
196              throw new Error('Schema index does not contain a schemas array');
197          }
198  
199          console.log(`Found ${schemaIndex.length} schema entries`);
200  
201          // Generate type map file
202          const typeMapEntries: string[] = [];
203  
204          // Process each schema in the index
205          for (const schemaInfo of schemaIndex) {
206              const { type, version, latest_url } = schemaInfo;
207  
208              // Skip base schema if it's not a specific block type
209              if (type === 'base') continue;
210  
211              const capitalizedType = type.charAt(0).toUpperCase() + type.slice(1);
212              const typeName = `${capitalizedType}Metadata`;
213  
214              // Use the latest_url from the API which already includes the /api/v1/ prefix
215              // or construct URL with the /api/v1/ prefix if not provided
216              const schemaUrl = latest_url ?
217                  (latest_url.startsWith('http') ? latest_url : `${baseUrl}${latest_url}`) :
218                  `${baseUrl}/api/v1/schemas/${type}/latest`;
219  
220              console.log(`Fetching schema for ${type} from: ${schemaUrl}`);
221  
222              try {
223                  const schemaContent = await fetchData(schemaUrl);
224                  const schema = JSON.parse(schemaContent);
225  
226                  // Generate combined file with both Zod schema and TypeScript interface
227                  const combinedContent = generateCombinedMetadataFile(schema, typeName);
228                  const filePath = path.join(outputDir, `${type}.ts`);
229                  fs.writeFileSync(filePath, combinedContent, 'utf-8');
230                  console.log(`Generated combined schema file: ${filePath}`);
231  
232                  // Add to type map
233                  typeMapEntries.push(`  ${type}: ${typeName};`);
234              } catch (error) {
235                  console.error(`Failed to generate schema for ${type}:`, error);
236              }
237          }
238  
239          // Generate the BlockMetadataByType map
240          if (typeMapEntries.length > 0) {
241              const typeMapContent = `import { MemoryBlockType } from '@/data/models/memoryBlockType';
242  ${schemaIndex
243                      .filter(s => s.type !== 'base')
244                      .map(s => {
245                          const capitalizedType = s.type.charAt(0).toUpperCase() + s.type.slice(1);
246                          return `import type { ${capitalizedType}Metadata } from './${s.type}';`;
247                      })
248                      .join('\n')}
249  
250  /**
251   * Type map to help with narrowing metadata types based on block type
252   */
253  export interface BlockMetadataByType {
254  ${typeMapEntries.join('\n')}
255  }
256  
257  /**
258   * Helper function to narrow metadata type based on block type
259   */
260  export function narrowMetadata<T extends MemoryBlockType>(
261    blockType: T,
262    metadata: unknown
263  ): BlockMetadataByType[T] {
264    return metadata as BlockMetadataByType[T];
265  }
266  `;
267              const typeMapPath = path.join(outputDir, 'index.ts');
268              fs.writeFileSync(typeMapPath, typeMapContent, 'utf-8');
269              console.log(`Generated BlockMetadataByType map: ${typeMapPath}`);
270          }
271  
272          console.log('✅ Metadata model generation complete!');
273      } catch (error) {
274          console.error('Error generating metadata models:', error);
275          throw error;
276      }
277  }
278  
279  // Main function to orchestrate the generation
280  (async () => {
281      const url = targets[env];
282      if (!url) throw new Error(`Unknown env ${env}`);
283      const baseUrl = new URL(url).origin;
284  
285      console.log(`📥 Fetching OpenAPI from ${url}`);
286      try {
287          const spec = await fetchData(url);
288  
289          // Create schemas directory if it doesn't exist
290          execSync('mkdir -p schemas');
291  
292          writeFileSync('schemas/openapi.json', spec);
293          console.log('✅ Saved schemas/openapi.json');
294  
295          console.log('🛠 Generating TypeScript types & component schemas');
296          execSync('mkdir -p src/types');
297          execSync('npx openapi-typescript schemas/openapi.json -o src/types/api.d.ts', {
298              stdio: 'inherit'
299          });
300  
301          console.log('🏗 Running Orval to generate API client');
302          execSync('npm run gen:api', {
303              stdio: 'inherit'
304          });
305  
306          // Generate metadata models
307          await generateMetadataModels(baseUrl);
308  
309          console.log('✨ All generation complete');
310      } catch (error) {
311          console.error('Error:', error);
312          process.exit(1);
313      }
314  })();