diff --git a/analyze.mts b/analyze.mts new file mode 100644 index 0000000..48672bc --- /dev/null +++ b/analyze.mts @@ -0,0 +1,322 @@ +import { execSync } from 'child_process' +import { JSONValue } from 'convex/values' +import fs from 'fs' +import path from 'path' + +/* +Usage: + npx ts-node-esm analyze.mts convex analyze + + Assumes there's already `convex/analyze` with a `helpers.ts` file + Outputs content in /tmp/analyzeResult +*/ + +type Visibility = { kind: 'public' } | { kind: 'internal' } + +type UdfType = 'action' | 'mutation' | 'query' | 'httpAction' + +export type AnalyzedFunctions = Array<{ + name: string + udfType: UdfType + visibility: Visibility | null + args: JSONValue | null +}> + +async function analyzeModule(filePath: string): Promise { + const importedModule = await import(filePath) + + const functions: Map< + string, + { + udfType: UdfType + visibility: Visibility | null + args: JSONValue | null + } + > = new Map() + for (const [name, value] of Object.entries(importedModule)) { + if (value === undefined || value === null) { + continue + } + + let udfType: UdfType + if ( + Object.prototype.hasOwnProperty.call(value, 'isAction') && + Object.prototype.hasOwnProperty.call(value, 'invokeAction') + ) { + udfType = 'action' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isQuery') && + Object.prototype.hasOwnProperty.call(value, 'invokeQuery') + ) { + udfType = 'query' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isMutation') && + Object.prototype.hasOwnProperty.call(value, 'invokeMutation') + ) { + udfType = 'mutation' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isHttp') && + (Object.prototype.hasOwnProperty.call(value, 'invokeHttpEndpoint') || + Object.prototype.hasOwnProperty.call(value, 'invokeHttpAction')) + ) { + udfType = 'httpAction' + } else { + continue + } + const isPublic = Object.prototype.hasOwnProperty.call(value, 'isPublic') + const isInternal = Object.prototype.hasOwnProperty.call(value, 'isInternal') + + let args: string | null = null + if ( + Object.prototype.hasOwnProperty.call(value, 'exportArgs') && + typeof (value as any).exportArgs === 'function' + ) { + const exportedArgs = (value as any).exportArgs() + if (typeof exportedArgs === 'string') { + args = JSON.parse(exportedArgs) + } + } + + if (isPublic && isInternal) { + console.debug( + `Skipping function marked as both public and internal: ${name}` + ) + continue + } else if (isPublic) { + functions.set(name, { udfType, visibility: { kind: 'public' }, args }) + } else if (isInternal) { + functions.set(name, { + udfType, + visibility: { kind: 'internal' }, + args, + }) + } else { + functions.set(name, { udfType, visibility: null, args }) + } + } + const analyzed = [...functions.entries()].map(([name, properties]) => { + // Finding line numbers is best effort. We should return the analyzed + // function even if we fail to find the exact line number. + return { + name, + ...properties, + } + }) + + return analyzed +} + +// Returns a generator of { isDir, path } for all paths +// within dirPath in some topological order (not including +// dirPath itself). +export function* walkDir( + dirPath: string +): Generator<{ isDir: boolean; path: string }, void, void> { + for (const dirEntry of fs + .readdirSync(dirPath, { withFileTypes: true }) + .sort()) { + const childPath = path.join(dirPath, dirEntry.name) + if (dirEntry.isDirectory()) { + yield { isDir: true, path: childPath } + yield* walkDir(childPath) + } else if (dirEntry.isFile()) { + yield { isDir: false, path: childPath } + } + } +} +export async function entryPoints( + dir: string, + verbose: boolean +): Promise { + const entryPoints = [] + + const log = (line: string) => { + if (verbose) { + console.log(line) + } + } + + for (const { isDir, path: fpath } of walkDir(dir)) { + if (isDir) { + continue + } + const relPath = path.relative(dir, fpath) + const base = path.parse(fpath).base + + if (relPath.startsWith('_deps' + path.sep)) { + throw new Error( + `The path "${fpath}" is within the "_deps" directory, which is reserved for dependencies. Please move your code to another directory.` + ) + } else if (relPath.startsWith('_generated' + path.sep)) { + log(`Skipping ${fpath}`) + } else if (base.startsWith('.')) { + log(`Skipping dotfile ${fpath}`) + } else if (base === 'README.md') { + log(`Skipping ${fpath}`) + } else if (base === '_generated.ts') { + log(`Skipping ${fpath}`) + } else if (base === 'schema.ts') { + log(`Skipping ${fpath}`) + } else if ((base.match(/\./g) || []).length > 1) { + log(`Skipping ${fpath} that contains multiple dots`) + } else if (base === 'tsconfig.json') { + log(`Skipping ${fpath}`) + } else if (relPath.endsWith('.config.js')) { + log(`Skipping ${fpath}`) + } else if (relPath.includes(' ')) { + log(`Skipping ${relPath} because it contains a space`) + } else if (base.endsWith('.d.ts')) { + log(`Skipping ${fpath} declaration file`) + } else if (base.endsWith('.json')) { + log(`Skipping ${fpath} json file`) + } else { + log(`Preparing ${fpath}`) + entryPoints.push(fpath) + } + } + + // If using TypeScript, require that at least one line starts with `export` or `import`, + // a TypeScript requirement. This prevents confusing type errors described in CX-5067. + const nonEmptyEntryPoints = entryPoints.filter((fpath) => { + // This check only makes sense for TypeScript files + if (!fpath.endsWith('.ts') && !fpath.endsWith('.tsx')) { + return true + } + const contents = fs.readFileSync(fpath, { encoding: 'utf-8' }) + if (/^\s{0,100}(import|export)/m.test(contents)) { + return true + } + log( + `Skipping ${fpath} because it has no export or import to make it a valid TypeScript module` + ) + }) + + return nonEmptyEntryPoints +} + +export type CanonicalizedModulePath = string + +export async function analyze( + convexDir: string +): Promise> { + const modules: Record = {} + const files = await entryPoints(convexDir, false) + for (const modulePath of files) { + const filePath = path.join(convexDir, modulePath) + modules[modulePath] = await analyzeModule(filePath) + } + return modules +} + +export function importPath(modulePath: string) { + // Replace backslashes with forward slashes. + const filePath = modulePath.replace(/\\/g, '/') + // Strip off the file extension. + const lastDot = filePath.lastIndexOf('.') + return filePath.slice(0, lastDot === -1 ? undefined : lastDot) +} + +function generateFile(paths: string[], filename: string, isNode: boolean) { + const imports: string[] = [] + const moduleGroupKeys: string[] = [] + for (const p of paths) { + const safeModulePath = importPath(p).replace(/\//g, '_').replace(/-/g, '_') + imports.push(`import * as ${safeModulePath} from "../${p}";`) + moduleGroupKeys.push(`"${p}": ${safeModulePath},`) + } + + const content = ` + ${isNode ? '"use node";' : ''} + import { internalAction } from "../_generated/server.js"; + import { analyzeModuleGroups } from "./helpers"; + ${imports.join('\n')} + export default internalAction((ctx) => { + return analyzeModuleGroups({ + ${moduleGroupKeys.join('\n')} + }) + }) + ` + fs.writeFileSync(filename, content) +} + +async function main(convexDir: string, analyzeDir: string) { + // analyzeDir is nested under convexDir and should contain a + // `helpers.ts` with a `analyzeModuleGroups` function + + // TODO: clear out analyzeDir + + // Get a list of modules split by module type + execSync('rm -rf /tmp/debug_bundle_path') + execSync('npx convex dev --once --debug-bundle-path /tmp/debug_bundle_path') + const outputStr = fs.readFileSync('/tmp/debug_bundle_path/fullConfig.json', { + encoding: 'utf-8', + }) + const output = JSON.parse(outputStr) + if (!fs.existsSync('/tmp/debugConvexDir')) { + fs.mkdirSync('/tmp/debugConvexDir') + } + const isolatePaths: string[] = [] + const nodePaths: string[] = [] + for (const m of output.modules) { + if (m.path.startsWith('_deps')) { + continue + } + if (m.path.startsWith(analyzeDir)) { + continue + } + if (m.path === 'schema.js') { + continue + } + if (m.path === 'auth.config.js') { + continue + } + if (m.environment === 'isolate') { + isolatePaths.push(m.path) + } else { + nodePaths.push(m.path) + } + } + + // Split these into chunks + const chunkSize = 10 + let chunkNumber = 0 + // Generate files in the analyze directory for each of these + for (let i = 0; i < isolatePaths.length; i += chunkSize) { + const chunk = isolatePaths.slice(i, i + chunkSize) + generateFile( + chunk, + `${convexDir}/${analyzeDir}/group${chunkNumber}.ts`, + false + ) + chunkNumber += 1 + } + for (let i = 0; i < nodePaths.length; i += chunkSize) { + const chunk = nodePaths.slice(i, i + chunkSize) + generateFile( + chunk, + `${convexDir}/${analyzeDir}/group${chunkNumber}.ts`, + true + ) + chunkNumber += 1 + } + + // Push our generated functions to dev + execSync('npx convex dev --once') + + // Run all the functions and collect the result + let fullResults: Record = {} + for (let i = 0; i < chunkNumber; i += 1) { + const result = execSync(`npx convex run ${analyzeDir}/group${i}:default`, { + maxBuffer: 2 ** 30, + }).toString() + console.log(result) + fullResults = { + ...fullResults, + ...JSON.parse(result), + } + } + fs.writeFileSync('/tmp/analyzeResult', JSON.stringify(fullResults, null, 2)) + console.log('Result written to /tmp/analyzeResult') +} + +await main(process.argv[2], process.argv[3]) diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 93e8a3e..7e1c7ed 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,10 +14,16 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as analyze_group0 from "../analyze/group0.js"; +import type * as analyze_group1 from "../analyze/group1.js"; +import type * as analyze_group2 from "../analyze/group2.js"; +import type * as analyze_group3 from "../analyze/group3.js"; +import type * as analyze_helpers from "../analyze/helpers.js"; import type * as cards from "../cards.js"; import type * as dealCards from "../dealCards.js"; import type * as functions from "../functions.js"; import type * as games from "../games.js"; +import type * as http from "../http.js"; import type * as lib_functions from "../lib/functions.js"; import type * as lib_middlewareUtils from "../lib/middlewareUtils.js"; import type * as lib_validators from "../lib/validators.js"; @@ -44,10 +50,16 @@ import type * as users from "../users.js"; * ``` */ declare const fullApi: ApiFromModules<{ + "analyze/group0": typeof analyze_group0; + "analyze/group1": typeof analyze_group1; + "analyze/group2": typeof analyze_group2; + "analyze/group3": typeof analyze_group3; + "analyze/helpers": typeof analyze_helpers; cards: typeof cards; dealCards: typeof dealCards; functions: typeof functions; games: typeof games; + http: typeof http; "lib/functions": typeof lib_functions; "lib/middlewareUtils": typeof lib_middlewareUtils; "lib/validators": typeof lib_validators; diff --git a/convex/analyze/group0.ts b/convex/analyze/group0.ts new file mode 100644 index 0000000..68427e0 --- /dev/null +++ b/convex/analyze/group0.ts @@ -0,0 +1,19 @@ + + + import { internalAction } from "../_generated/server.js"; + import { analyzeModuleGroups } from "./helpers"; + import * as players from "../players.js"; +import * as prosetHelpers from "../prosetHelpers.js"; +import * as queries_getOngoingGames from "../queries/getOngoingGames.js"; +import * as revealProset from "../revealProset.js"; +import * as types_game_info from "../types/game_info.js"; + export default internalAction((ctx) => { + return analyzeModuleGroups({ + "players.js": players, +"prosetHelpers.js": prosetHelpers, +"queries/getOngoingGames.js": queries_getOngoingGames, +"revealProset.js": revealProset, +"types/game_info.js": types_game_info, + }) + }) + \ No newline at end of file diff --git a/convex/analyze/group1.ts b/convex/analyze/group1.ts new file mode 100644 index 0000000..3274461 --- /dev/null +++ b/convex/analyze/group1.ts @@ -0,0 +1,19 @@ + + + import { internalAction } from "../_generated/server.js"; + import { analyzeModuleGroups } from "./helpers"; + import * as types_player_colors from "../types/player_colors.js"; +import * as users from "../users.js"; +import * as lib_middlewareUtils from "../lib/middlewareUtils.js"; +import * as lib_validators from "../lib/validators.js"; +import * as message from "../message.js"; + export default internalAction((ctx) => { + return analyzeModuleGroups({ + "types/player_colors.js": types_player_colors, +"users.js": users, +"lib/middlewareUtils.js": lib_middlewareUtils, +"lib/validators.js": lib_validators, +"message.js": message, + }) + }) + \ No newline at end of file diff --git a/convex/analyze/group2.ts b/convex/analyze/group2.ts new file mode 100644 index 0000000..9603fd0 --- /dev/null +++ b/convex/analyze/group2.ts @@ -0,0 +1,19 @@ + + + import { internalAction } from "../_generated/server.js"; + import { analyzeModuleGroups } from "./helpers"; + import * as model_cards from "../model/cards.js"; +import * as model_game from "../model/game.js"; +import * as model_message from "../model/message.js"; +import * as model_player from "../model/player.js"; +import * as model_user from "../model/user.js"; + export default internalAction((ctx) => { + return analyzeModuleGroups({ + "model/cards.js": model_cards, +"model/game.js": model_game, +"model/message.js": model_message, +"model/player.js": model_player, +"model/user.js": model_user, + }) + }) + \ No newline at end of file diff --git a/convex/analyze/group3.ts b/convex/analyze/group3.ts new file mode 100644 index 0000000..97bcacd --- /dev/null +++ b/convex/analyze/group3.ts @@ -0,0 +1,19 @@ + + + import { internalAction } from "../_generated/server.js"; + import { analyzeModuleGroups } from "./helpers"; + import * as cards from "../cards.js"; +import * as dealCards from "../dealCards.js"; +import * as functions from "../functions.js"; +import * as games from "../games.js"; +import * as lib_functions from "../lib/functions.js"; + export default internalAction((ctx) => { + return analyzeModuleGroups({ + "cards.js": cards, +"dealCards.js": dealCards, +"functions.js": functions, +"games.js": games, +"lib/functions.js": lib_functions, + }) + }) + \ No newline at end of file diff --git a/convex/analyze/helpers.ts b/convex/analyze/helpers.ts new file mode 100644 index 0000000..7537433 --- /dev/null +++ b/convex/analyze/helpers.ts @@ -0,0 +1,98 @@ +export const analyzeModuleGroups = (moduleGroup: Record) => { + const analyzed: Record = {} + for (const modulePath in moduleGroup) { + analyzeModule(moduleGroup[modulePath], modulePath, analyzed) + } + + return analyzed +} + +type UdfType = 'action' | 'mutation' | 'query' | 'httpAction' + +const analyzeModule = ( + importedModule: any, + modulePath: string, + analyzedResults: Record +) => { + for (const exportName of Object.keys(importedModule)) { + const value = importedModule[exportName] as any + if (value === undefined || value === null) { + continue + } + let udfType: UdfType + if ( + Object.prototype.hasOwnProperty.call(value, 'isAction') && + Object.prototype.hasOwnProperty.call(value, 'invokeAction') + ) { + udfType = 'action' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isQuery') && + Object.prototype.hasOwnProperty.call(value, 'invokeQuery') + ) { + udfType = 'query' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isMutation') && + Object.prototype.hasOwnProperty.call(value, 'invokeMutation') + ) { + udfType = 'mutation' + } else if ( + Object.prototype.hasOwnProperty.call(value, 'isHttp') && + (Object.prototype.hasOwnProperty.call(value, 'invokeHttpEndpoint') || + Object.prototype.hasOwnProperty.call(value, 'invokeHttpAction')) + ) { + udfType = 'httpAction' + } else { + continue + } + const isPublic = Object.prototype.hasOwnProperty.call(value, 'isPublic') + const isInternal = Object.prototype.hasOwnProperty.call(value, 'isInternal') + + let args: string | null = null + if ( + Object.prototype.hasOwnProperty.call(value, 'exportArgs') && + typeof (value as any).exportArgs === 'function' + ) { + const exportedArgs = (value as any).exportArgs() + if (typeof exportedArgs === 'string') { + args = JSON.parse(exportedArgs) + } + } + + let output: string | null = null + if ( + Object.prototype.hasOwnProperty.call(value, 'exportReturns') && + typeof (value as any).exportReturns === 'function' + ) { + const exportedOutput = (value as any).exportReturns() + if (typeof exportedOutput === 'string') { + output = JSON.parse(exportedOutput) + } + } + + if (isPublic && isInternal) { + continue + } else if (isPublic) { + analyzedResults[`${modulePath}:${exportName}`] = { + udfType, + visibility: { kind: 'public' }, + args, + output, + } + } else if (isInternal) { + analyzedResults[`${modulePath}:${exportName}`] = { + udfType, + visibility: { kind: 'internal' }, + args, + output, + } + } else { + analyzedResults[`${modulePath}:${exportName}`] = { + udfType, + visibility: null, + args, + output, + } + } + } + return analyzedResults +} diff --git a/convex/http.ts b/convex/http.ts new file mode 100644 index 0000000..5c78781 --- /dev/null +++ b/convex/http.ts @@ -0,0 +1,120 @@ +import { httpRouter, makeFunctionReference } from 'convex/server' +import { ConvexError } from 'convex/values' +import { httpAction } from './_generated/server' + +const http = httpRouter() +const headers = { + 'Access-Control-Allow-Origin': '*', + Vary: 'origin', + 'content-type': 'application/json', +} + +const wrapWithErrorHandler = (p: Promise): Promise => { + return p + .then((result) => { + return new Response( + JSON.stringify({ status: 'success', value: result }), + { + status: 200, + headers, + } + ) + }) + .catch((e) => { + if (e instanceof ConvexError) { + return new Response( + JSON.stringify({ + status: 'error', + errorMessage: e.message, + errorData: e.data, + }), + { + status: 500, + headers: { 'content-type': 'application/json' }, + } + ) + } else { + return new Response( + JSON.stringify({ + status: 'error', + errorMessage: (e as any).message, + errorData: {}, + }), + { + status: 500, + headers, + } + ) + } + }) +} + +http.route({ + pathPrefix: '/api/', + method: 'POST', + handler: httpAction(async (ctx, request) => { + const body = await request.json() + const args = body.args ?? {} + const pathParts = new URL(request.url).pathname.split('/') + console.log(pathParts) + const [_empty, _api, udfType, ...rest] = pathParts + const functionName = rest.pop() + const functionReference = `${rest.join('/')}:${functionName}` + switch (udfType) { + case 'query': { + return await wrapWithErrorHandler( + ctx.runQuery(makeFunctionReference<'query'>(functionReference), args) + ) + } + case 'mutation': { + return await wrapWithErrorHandler( + ctx.runMutation( + makeFunctionReference<'mutation'>(functionReference), + args + ) + ) + } + case 'action': + return await wrapWithErrorHandler( + ctx.runAction( + makeFunctionReference<'action'>(functionReference), + args + ) + ) + default: + return new Response( + JSON.stringify({ + status: 'error', + errorMessage: `Unexpected function type: ${udfType}`, + }), + { status: 400, headers } + ) + } + }), +}) + +http.route({ + pathPrefix: '/api/', + method: 'OPTIONS', + handler: httpAction(async (_, request) => { + const headers = request.headers + if ( + headers.get('Origin') !== null && + headers.get('Access-Control-Request-Method') !== null && + headers.get('Access-Control-Request-Headers') !== null + ) { + return new Response(null, { + headers: new Headers({ + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST', + 'Access-Control-Allow-Headers': 'Content-Type, Digest', + 'Access-Control-Max-Age': '86400', + }), + }) + } else { + return new Response() + } + }), +}) + +export default http diff --git a/generateApiFile.mts b/generateApiFile.mts new file mode 100644 index 0000000..925d4c2 --- /dev/null +++ b/generateApiFile.mts @@ -0,0 +1,132 @@ +import { JSONValue } from 'convex/values' +import fs from 'fs' +/* +Usage: + npx ts-node-esm generateApiFile.mts /tmp/analyzeResult > destinationFile.ts +*/ + +export type ObjectFieldType = { fieldType: ValidatorJSON; optional: boolean } +export type ValidatorJSON = + | { + type: 'null' + } + | { type: 'number' } + | { type: 'bigint' } + | { type: 'boolean' } + | { type: 'string' } + | { type: 'bytes' } + | { type: 'any' } + | { + type: 'literal' + value: JSONValue + } + | { type: 'id'; tableName: string } + | { type: 'array'; value: ValidatorJSON } + | { type: 'record'; keys: ValidatorJSON; values: ObjectFieldType } + | { type: 'object'; value: Record } + | { type: 'union'; value: ValidatorJSON[] } + +function generateArgsType(argsJson: ValidatorJSON): string { + switch (argsJson.type) { + case 'null': + return 'null' + case 'number': + return 'number' + case 'bigint': + return 'bigint' + case 'boolean': + return 'boolean' + case 'string': + return 'string' + case 'bytes': + return 'ArrayBuffer' + case 'any': + return 'any' + case 'literal': + if (typeof argsJson.value === 'string') { + return `"${argsJson.value}"` as string + } else { + return argsJson.value!.toString() + } + case 'id': + return `Id<"${argsJson.tableName}">` + case 'array': + return `Array<${generateArgsType(argsJson.value)}>` + case 'record': + return 'any' + case 'object': { + const members: string[] = Object.entries(argsJson.value).map( + ([key, value]) => { + return `${key}${value.optional ? '?' : ''}: ${generateArgsType( + value.fieldType + )},` + } + ) + if (members.length === 0) { + // special case empty object + return 'Record' + } + return `{ ${members.join('\n')} }` + } + case 'union': { + const members: string[] = argsJson.value.map((v) => generateArgsType(v)) + return members.join(' | ') + } + } +} + +function generateApiType(tree: Record) { + const isFunction = tree.udfType !== undefined + if (isFunction) { + const output = + tree.output === null || tree.output === undefined + ? 'any' + : generateArgsType(tree.output) + return `FunctionReference<"${tree.udfType}", "${ + tree.visibility.kind + }", ${generateArgsType(tree.args)}, ${output}>` + } + const members: string[] = Object.entries(tree).map(([key, value]) => { + return `${key}: ${generateApiType(value)}` + }) + return `{ ${members.join('\n')} }` +} + +async function main(analyzeResultFile: string) { + const analyzeResult = JSON.parse( + fs.readFileSync(analyzeResultFile, { encoding: 'utf-8' }) + ) + const publicFunctionTree: Record = {} + const internalFunctionTree: Record = {} + for (const functionPath in analyzeResult) { + const analyzedFunction = analyzeResult[functionPath] + const [modulePath, functionName] = functionPath.split(':') + const withoutExtension = modulePath.slice(0, modulePath.length - 3) + const pathParts = withoutExtension.split('/') + let treeNode = + analyzedFunction.visibility.kind === 'internal' + ? internalFunctionTree + : publicFunctionTree + for (let i = 0; i < pathParts.length; i += 1) { + const pathPart = pathParts[i] + if (treeNode[pathPart] === undefined) { + treeNode[pathPart] = {} + } + treeNode = treeNode[pathPart] + } + treeNode[functionName] = analyzedFunction + } + const apiType = generateApiType(publicFunctionTree) + const internalApiType = generateApiType(internalFunctionTree) + console.log(` + import { FunctionReference, anyApi } from "convex/server" + import { GenericId as Id } from "convex/values" + + export type PublicApiType = ${apiType} + export type InternalApiType = ${internalApiType} + export const api: PublicApiType = anyApi as unknown as PublicApiType; + export const internal: InternalApiType = anyApi as unknown as InternalApiType; + `) +} + +await main(process.argv[2]) diff --git a/generateOpenApiSpec.mts b/generateOpenApiSpec.mts new file mode 100644 index 0000000..f439da5 --- /dev/null +++ b/generateOpenApiSpec.mts @@ -0,0 +1,253 @@ +import { JSONValue } from 'convex/values' +import fs from 'fs' +/* +Usage: + npx ts-node-esm generateOpenApiSpec.mts /tmp/analyzeResult +*/ + +type Visibility = { kind: 'public' } | { kind: 'internal' } + +type UdfType = 'action' | 'mutation' | 'query' | 'httpAction' + +export type AnalyzedFunction = { + udfType: UdfType + visibility: Visibility | null + args: ValidatorJSON | null + output: ValidatorJSON | null +} + +export type ObjectFieldType = { fieldType: ValidatorJSON; optional: boolean } +export type ValidatorJSON = + | { + type: 'null' + } + | { type: 'number' } + | { type: 'bigint' } + | { type: 'boolean' } + | { type: 'string' } + | { type: 'bytes' } + | { type: 'any' } + | { + type: 'literal' + value: JSONValue + } + | { type: 'id'; tableName: string } + | { type: 'array'; value: ValidatorJSON } + | { type: 'record'; keys: ValidatorJSON; values: ObjectFieldType } + | { type: 'object'; value: Record } + | { type: 'union'; value: ValidatorJSON[] } + +function generateSchemaFromValidator(validatorJson: ValidatorJSON): string { + switch (validatorJson.type) { + case 'null': + // kind of a hack + return 'type: string\nnullable: true' + case 'number': + return 'type: number' + case 'bigint': + throw new Error('bigint unsupported') + case 'boolean': + return 'type: boolean' + case 'string': + return 'type: string' + case 'bytes': + throw new Error('bytes unsupported') + case 'any': + // TODO: real any type + return 'type: object' + case 'literal': + if (typeof validatorJson.value === 'string') { + return `type: string\nenum:\n - "${validatorJson.value}"` as string + } else if (typeof validatorJson.value === 'boolean') { + return `type: boolean\nenum:\n - ${validatorJson.value.toString()}` + } else { + return `type: number\nenum:\n - ${validatorJson.value!.toString()}` + } + case 'id': + return `type: string\ndescription: ID from table "${validatorJson.tableName}"` + case 'array': + return `type: array\nitems:\n${reindent( + generateSchemaFromValidator(validatorJson.value), + 1 + )}` + case 'record': + return 'type: object' + case 'object': { + const requiredProperties: string[] = [] + const members: string[] = Object.entries(validatorJson.value).map( + ([key, value]) => { + if (!value.optional) { + requiredProperties.push(key) + } + return `${key}:\n${reindent( + generateSchemaFromValidator(value.fieldType), + 1 + )}` + } + ) + const requiredPropertiesStr = + requiredProperties.length === 0 + ? '' + : `required:\n${reindent( + requiredProperties.map((r) => `- ${r}`).join('\n'), + 1 + )}\n` + const propertiesStr = + members.length === 0 + ? '' + : `properties:\n${reindent(members.join('\n'), 1)}` + return `type: object\n${requiredPropertiesStr}${propertiesStr}` + } + case 'union': { + const nullMember = validatorJson.value.find((v) => v.type === 'null') + const nonNullMembers = validatorJson.value.filter( + (v) => v.type !== 'null' + ) + if (nonNullMembers.length === 1 && nullMember !== undefined) { + return `${generateSchemaFromValidator( + nonNullMembers[0] + )}\nnullable: true` + } + const members: string[] = nonNullMembers.map((v) => + generateSchemaFromValidator(v) + ) + return `${ + nullMember === undefined ? '' : 'nullable: true\n' + }oneOf:\n${members.map((m) => reindent(m, 1, true)).join('\n')}` + } + } +} + +function reindent( + linesStr: string, + indentation: number, + firstLineList: boolean = false +) { + const lines = linesStr.split('\n') + return lines + .map((l, index) => { + if (index === 0 && firstLineList) { + return `- ${' '.repeat(indentation - 1)}${l}` + } + return `${' '.repeat(indentation)}${l}` + }) + .join('\n') +} + +function formatName(name: string) { + const [modulePath, functionName] = name.split(':') + const withoutExtension = modulePath.slice(0, modulePath.length - 3) + const pathParts = withoutExtension.split('/') + const shortName = `${pathParts.join('.')}.${functionName}` + const urlPathName = `${pathParts.join('/')}/${functionName}` + return { + original: name, + shortName, + urlPathName, + } +} + +function generateEndpointDef(name: string, func: AnalyzedFunction) { + const { urlPathName, shortName } = formatName(name) + return ` +/api/${func.udfType}/${urlPathName}: + post: + tags: + - ${func.udfType} + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Request_${shortName}' + required: true + responses: + '200': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Response_${shortName}' + '500': + description: Successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/FailedResponse'\n` +} + +function generateEndpointSchemas(name: string, func: AnalyzedFunction) { + const { shortName } = formatName(name) + return ` +Request_${shortName}: + type: object + required: + - args + properties: + args:\n${reindent( + generateSchemaFromValidator(func.args ?? { type: 'any' }), + 4 + )}\n +Response_${shortName}: + type: object + properties: + status: + type: string + enum: + - "success" + value:\n${reindent( + generateSchemaFromValidator(func.output ?? { type: 'any' }), + 4 + )}\n` +} + +function generateOpenApiSpec(analyzeResult: Record) { + const siteUrl = 'https://bitter-narwhal-770.convex.site' + return ` +openapi: 3.0.3 +info: + title: My Cool Convex App - OpenAPI 3.0 + version: 0.0.0 +servers: + - url: ${siteUrl} +tags: + - name: query + - name: mutation + - name: action +paths: +${reindent( + Object.entries(analyzeResult) + .map(([key, value]) => generateEndpointDef(key, value)) + .join('\n'), + 1 +)} +components: + schemas: +${reindent( + Object.entries(analyzeResult) + .map(([key, value]) => generateEndpointSchemas(key, value)) + .join('\n'), + 2 +)} + FailedResponse: + type: object + properties: + status: + type: string + enum: + - "error" + errorMessage: + type: string + errorData: + type: object +` +} + +async function main(analyzeResultFile: string) { + const analyzeResult: Record = JSON.parse( + fs.readFileSync(analyzeResultFile, { encoding: 'utf-8' }) + ) + const spec = generateOpenApiSpec(analyzeResult) + console.log(spec) +} + +await main(process.argv[2]) diff --git a/myApi.ts b/myApi.ts new file mode 100644 index 0000000..4834c93 --- /dev/null +++ b/myApi.ts @@ -0,0 +1,279 @@ +import { FunctionReference, anyApi } from 'convex/server' +import { GenericId as Id } from 'convex/values' + +export type PublicApiType = { + players: { + joinGame: FunctionReference< + 'mutation', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + null + > + } + queries: { + getOngoingGames: { + default: FunctionReference< + 'query', + 'public', + { sessionId: string | null }, + any + > + } + } + revealProset: { + default: FunctionReference< + 'mutation', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + null + > + } + message: { + list: FunctionReference< + 'query', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + Array<{ + GameId: Id<'Games'> + _creationTime: number + _id: Id<'Messages'> + content: string + player: null | string + }> + > + send: FunctionReference< + 'mutation', + 'public', + { + content: string + gameId: Id<'Games'> + isPrivate?: boolean + sessionId: string + }, + any + > + } + users: { + completeOnboarding: FunctionReference< + 'mutation', + 'public', + { sessionId: string }, + any + > + getOrCreate: FunctionReference< + 'mutation', + 'public', + { sessionId: string }, + Id<'Users'> + > + getOrNull: FunctionReference< + 'query', + 'public', + { sessionId: string }, + null | { + _creationTime: number + _id: Id<'Users'> + identifier: string + isGuest: boolean + name: string + showOnboarding: boolean + } + > + } + cards: { + reveal: FunctionReference< + 'mutation', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + null + > + select: FunctionReference< + 'mutation', + 'public', + { cardId: Id<'PlayingCards'>; gameId: Id<'Games'>; sessionId: string }, + null + > + startSelectSet: FunctionReference< + 'mutation', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + null | { reason: string; selectedBy: Id<'Players'> } + > + } + dealCards: { + default: FunctionReference< + 'query', + 'public', + { + gameId: Id<'Games'> + paginationOpts: { + cursor: string | null + endCursor?: string | null + id?: number + maximumBytesRead?: number + maximumRowsRead?: number + numItems: number + } + }, + { + continueCursor: string + isDone: boolean + page: Array<{ + GameId: Id<'Games'> + _creationTime: number + _id: Id<'PlayingCards'> + blue: boolean + green: boolean + orange: boolean + proset: null | Id<'Prosets'> + purple: boolean + rank: number + red: boolean + selectedBy: null | Id<'Players'> + yellow: boolean + }> + pageStatus?: 'SplitRecommended' | 'SplitRequired' | null + splitCursor?: string | null + } + > + } + games: { + end: FunctionReference< + 'mutation', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + any + > + getInfo: FunctionReference< + 'query', + 'public', + { gameId: Id<'Games'>; sessionId: string }, + { + currentPlayer: { + GameId: Id<'Games'> + UserId: Id<'Users'> + _creationTime: number + _id: Id<'Players'> + color: + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'grey' + isGuest: boolean + isSystemPlayer: boolean + name: string + score: number + showOnboarding: boolean + } + game: { + _creationTime: number + _id: Id<'Games'> + deletionTime?: number + inProgress: boolean + isPublic?: boolean + name: string + selectingPlayer: null | Id<'Players'> + selectionStartTime: null | number + } + otherPlayers: Array<{ + GameId: Id<'Games'> + UserId: Id<'Users'> + _creationTime: number + _id: Id<'Players'> + color: + | 'red' + | 'orange' + | 'yellow' + | 'green' + | 'blue' + | 'purple' + | 'grey' + isSystemPlayer: boolean + name: string + score: number + }> + playerToProsets: any + } + > + getOrCreate: FunctionReference< + 'mutation', + 'public', + { sessionId: string }, + any + > + start: FunctionReference<'mutation', 'public', { sessionId: string }, any> + } +} +export type InternalApiType = { + message: { + remove: FunctionReference< + 'mutation', + 'internal', + { messageId: Id<'Messages'> }, + any + > + } + cards: { + claimSet: FunctionReference< + 'mutation', + 'internal', + { gameId: Id<'Games'>; playerId: Id<'Players'> }, + null + > + discardRevealedProset: FunctionReference< + 'mutation', + 'internal', + { cardIds: Array>; gameId: Id<'Games'> }, + null + > + maybeClearSelectSet: FunctionReference< + 'mutation', + 'internal', + { gameId: Id<'Games'> }, + null + > + } + functions: { + scheduledDelete: FunctionReference< + 'mutation', + 'internal', + { + inProgress: boolean + origin: { deletionTime: number; id: string; table: string } + stack: Array< + | { + edges: Array<{ + approach: 'cascade' | 'paginate' + indexName: string + table: string + }> + id: string + table: string + } + | { + approach: 'cascade' | 'paginate' + cursor: string | null + fieldValue: any + indexName: string + table: string + } + > + }, + any + > + } + games: { + cleanup: FunctionReference< + 'mutation', + 'internal', + { gameId: Id<'Games'> }, + any + > + setup: FunctionReference<'mutation', 'internal', Record, any> + } +} +export const api: PublicApiType = anyApi as unknown as PublicApiType +export const internal: InternalApiType = anyApi as unknown as InternalApiType diff --git a/tsconfig.json b/tsconfig.json index 5f41869..41618de 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,8 +13,14 @@ "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", - "incremental": true, + "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + "analyze.mts", + "generateApiFile.mts" + ], "exclude": ["node_modules"] }