UNPKG

@murbagus/typespec-domaingin-emitter

Version:

TypeSpec emitter that generates Go domain logic and Gin (github.com/gin-gonic/gin) HTTP handlers following my own project structure and coding standards.

733 lines 32.5 kB
import { createTypeSpecLibrary, getDoc, getSourceLocation, navigateProgram, } from "@typespec/compiler"; import * as fs from "fs"; import * as path from "path"; import { getDomainGinHandlerName, isDomainGinHandlerGen, } from "./decorators.js"; const libDef = createTypeSpecLibrary({ name: "typespec-go-emitter", diagnostics: {}, }); export const { name, reportDiagnostic } = libDef; const defaultOptions = { "emitter-output-dir": "../application/domain", "handler-output-dir": "../drivers/delivery/http", "generate-comment": "//!Generate", }; export async function $onEmit(context) { const options = { ...defaultOptions, ...context.options }; const emitter = new GoEmitter(context, options); emitter.emitProgram(); } class GoEmitter { context; options; program; outputDir; handlerOutputDir; processedModelFiles = new Set(); modelsByFile = new Map(); operationsByFile = new Map(); constructor(context, options) { this.context = context; this.options = options; this.program = context.program; // Use the exact paths from tspconfig.yaml, TypeSpec already resolved {project-root} // If paths are absolute (resolved by TypeSpec), use them directly // Otherwise use context.emitterOutputDir as base if (options["emitter-output-dir"] && path.isAbsolute(options["emitter-output-dir"])) { this.outputDir = options["emitter-output-dir"]; } else { this.outputDir = context.emitterOutputDir; } if (options["handler-output-dir"] && path.isAbsolute(options["handler-output-dir"])) { this.handlerOutputDir = options["handler-output-dir"]; } else { this.handlerOutputDir = path.resolve(context.emitterOutputDir, "../handlers"); } } emitProgram() { this.ensureOutputDirectories(); this.collectTypes(); this.emitModels(); this.emitHandlers(); } ensureOutputDirectories() { if (!fs.existsSync(this.outputDir)) { fs.mkdirSync(this.outputDir, { recursive: true }); } if (!fs.existsSync(this.handlerOutputDir)) { fs.mkdirSync(this.handlerOutputDir, { recursive: true }); } } collectTypes() { navigateProgram(this.program, { model: (model) => { const sourceFile = getSourceLocation(model)?.file; if (sourceFile) { // Handle models from /models/ directory (except common.tsp) if (sourceFile.path.includes("/models/") && !sourceFile.path.includes("common.tsp")) { const fileName = path.basename(sourceFile.path, ".tsp"); if (!this.processedModelFiles.has(fileName)) { this.processedModelFiles.add(fileName); this.modelsByFile.set(fileName, []); } this.modelsByFile.get(fileName)?.push(model); } // Handle models from /routes/ directory for request structures if (sourceFile.path.includes("/routes/")) { const fileName = path.basename(sourceFile.path, ".tsp"); if (!this.operationsByFile.has(fileName)) { this.operationsByFile.set(fileName, { operations: [], models: [], namespaces: [], operationsWithDecorator: new Set(), }); } // Only collect models that look like request structures if (model.name.toLowerCase().includes("request") || model.name.toLowerCase().includes("create") || model.name.toLowerCase().includes("update")) { this.operationsByFile.get(fileName)?.models.push(model); } } } }, namespace: (namespace) => { const sourceFile = getSourceLocation(namespace)?.file; if (sourceFile && sourceFile.path.includes("/routes/")) { const fileName = path.basename(sourceFile.path, ".tsp"); if (!this.operationsByFile.has(fileName)) { this.operationsByFile.set(fileName, { operations: [], models: [], namespaces: [], operationsWithDecorator: new Set(), }); } // Store namespace for decorator checking this.operationsByFile.get(fileName).namespaces.push(namespace); } }, operation: (operation) => { const sourceFile = getSourceLocation(operation)?.file; if (sourceFile && sourceFile.path.includes("/routes/")) { const fileName = path.basename(sourceFile.path, ".tsp"); if (!this.operationsByFile.has(fileName)) { this.operationsByFile.set(fileName, { operations: [], models: [], namespaces: [], operationsWithDecorator: new Set(), }); } this.operationsByFile.get(fileName)?.operations.push(operation); // Check if operation has @domainGinHandlerGen decorator if (isDomainGinHandlerGen(this.program, operation)) { this.operationsByFile .get(fileName) ?.operationsWithDecorator.add(operation); } } }, }); } emitModels() { for (const [fileName, models] of this.modelsByFile) { this.emitModelFileFromModels(models, fileName); } } emitModelFileFromModels(models, fileName) { const outputPath = path.join(this.outputDir, `${fileName}.go`); let goCode = ""; let existingContent = ""; // Check if file already exists if (fs.existsSync(outputPath)) { existingContent = fs.readFileSync(outputPath, "utf8"); // Find the separator comment (try both variations) let separatorIndex = existingContent.indexOf("// --- Generated by typespec ---"); if (separatorIndex === -1) { separatorIndex = existingContent.indexOf("// --- Generaterd by typespec ---"); // Handle typo } if (separatorIndex !== -1) { // Keep everything before the separator existingContent = existingContent.substring(0, separatorIndex).trim(); if (existingContent) { goCode = `${existingContent}\n\n`; } else { goCode = `package domain\n\n`; } } else { // If no separator found, assume all content is user-written if (existingContent.trim()) { goCode = `${existingContent.trim()}\n\n`; } else { goCode = `package domain\n\n`; } } } else { goCode = `package domain\n\n`; } // Add separator comment goCode += `// --- Generated by typespec ---\n\n`; // Add generated models for (const model of models) { goCode += this.emitModel(model); } fs.writeFileSync(outputPath, goCode); } emitModel(model) { const modelName = model.name; const doc = getDoc(this.program, model); let code = ""; // Add model comment if (doc) { code += `// ${modelName} ${doc}\n`; } else { code += `// ${modelName} represents the ${modelName.toLowerCase()} data structure\n`; } code += `type ${modelName} struct {\n`; // Add fields for (const [propName, prop] of model.properties) { code += this.emitModelProperty(prop); } code += `}\n\n`; return code; } emitModelProperty(prop) { const propDoc = getDoc(this.program, prop); const fieldName = this.toPascalCase(prop.name); // Check if property is optional or has nullable type const isNullable = prop.optional || this.isPropertyNullable(prop); const goType = this.typeToGoType(prop.type, isNullable); const jsonTag = `json:"${prop.name}"`; let code = ""; code += `\t${fieldName} ${goType} \`${jsonTag}\`\n`; return code; } isPropertyNullable(prop) { return prop.type.kind === "Union" && this.isNullableUnion(prop.type); } emitHandlers() { for (const [fileName, data] of this.operationsByFile) { // Find namespace with @domainGinHandlerGen decorator const namespaceWithDecorator = data.namespaces.find((ns) => isDomainGinHandlerGen(this.program, ns)); let shouldGenerate = false; let operationsToGenerate = []; let targetNamespace; // Check for @domainGinHandlerGen decorator usage const namespaceHasDecorator = !!namespaceWithDecorator; const hasOperationDecorators = data.operationsWithDecorator.size > 0; if (hasOperationDecorators) { // If any operation has @domainGinHandlerGen, only generate those operations // Even if namespace has decorator, operation-level takes precedence shouldGenerate = true; operationsToGenerate = Array.from(data.operationsWithDecorator); // For each operation, we need to find its specific namespace during emission // Don't set targetNamespace here as operations may be in different namespaces } else if (namespaceHasDecorator) { // If namespace has @domainGinHandlerGen and no operations have it, generate all operations shouldGenerate = true; operationsToGenerate = data.operations; targetNamespace = namespaceWithDecorator; } else { // Fall back to old comment-based generation const sourceFiles = this.program.sourceFiles; const generateComment = this.options["generate-comment"] || "//!Generate"; for (const [filePath, sourceFile] of sourceFiles) { if (filePath.includes(`/routes/${fileName}.tsp`)) { const fileContent = fs.readFileSync(filePath, "utf8"); // Escape special regex characters and create pattern const escapedComment = generateComment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp(escapedComment.replace(/\s+/g, "\\s*"), "i"); shouldGenerate = pattern.test(fileContent); if (shouldGenerate) { operationsToGenerate = data.operations; } break; } } } if (shouldGenerate && operationsToGenerate.length > 0) { this.emitHandlerFile(operationsToGenerate, data.models, fileName, targetNamespace, data.namespaces // Pass all namespaces for operation-specific lookup ); } } } emitHandlerFile(operations, models, fileName, namespace, allNamespaces) { let goCode = `package http\n\n`; // Emit handler functions (no separate request structs since we use inline) for (const operation of operations) { // Find the specific namespace for this operation let operationNamespace = namespace; if (allNamespaces && !namespace) { // Find the namespace that contains this operation operationNamespace = this.findNamespaceForOperation(operation, allNamespaces); } goCode += this.emitHandler(operation, operationNamespace); } // Handle file writing based on whether file exists const outputPath = path.join(this.handlerOutputDir, `${fileName}.handler.go`); if (fs.existsSync(outputPath)) { // Append new handlers to existing file const existingContent = fs.readFileSync(outputPath, "utf8"); const newContent = existingContent + "\n" + goCode.replace("package http\n\n", ""); fs.writeFileSync(outputPath, newContent); } else { // Create new file fs.writeFileSync(outputPath, goCode); } } findNamespaceForOperation(operation, namespaces) { // Find the namespace that contains this operation by checking operation's namespace const operationNamespace = operation.namespace; if (operationNamespace) { // Find matching namespace from our collected namespaces return namespaces.find((ns) => ns.name === operationNamespace.name); } return undefined; } emitHandler(operation, namespace) { const operationName = operation.name; const doc = getDoc(this.program, operation); // Determine handler name using decorator let handlerName = "Handler"; // default // Check for @domainGinHandlerName on operation first const operationHandlerName = getDomainGinHandlerName(this.program, operation); if (operationHandlerName) { handlerName = operationHandlerName; } else if (namespace) { // Check for @domainGinHandlerName on namespace const namespaceHandlerName = getDomainGinHandlerName(this.program, namespace); if (namespaceHandlerName) { handlerName = namespaceHandlerName; } } let code = ""; // Add operation comment if (doc) { code += `// ${operationName} ${doc}\n`; } else { code += `// ${operationName} handles the ${operationName.toLowerCase()} operation\n`; } // Check for authentication requirements const authInfo = this.getAuthenticationInfo(operation); if (authInfo) { code += `// Authentication: ${authInfo}\n`; } // Add query and path parameters comment const parameterComments = this.getParameterComments(operation); if (parameterComments.length > 0) { code += parameterComments.join(""); } // Add response comment code += `// Response: Expected response type based on operation definition\n`; code += `func (h *${handlerName}) ${operationName}(c *gin.Context) {\n`; // Generate path and query parameter variables (commented) const paramVariables = this.generateParameterVariables(operation); if (paramVariables.length > 0) { code += paramVariables.join(""); } // Generate request body struct if operation has body const requestStruct = this.generateRequestStructInline(operation); if (requestStruct) { code += requestStruct; } code += `\t// TODO: Implement your handler logic for ${operationName} here 🚀 (by Muhammad Refy) \n`; code += `\tc.JSON(200, gin.H{"message": "Not implemented"})\n`; code += `}\n\n`; return code; } buildValidationTags(prop) { // Use the detailed validation tags method return this.buildValidationTagsDetailed(prop); } typeToGoType(type, supportNull = false) { switch (type.kind) { case "Scalar": return this.scalarToGoType(type.name, supportNull); case "Model": if (type.name === "Array" && type.indexer) { const elementType = this.typeToGoType(type.indexer.value, supportNull); return `[]${elementType}`; } // For model types that can be null, use pointer if (supportNull) { return `*${type.name}`; } return type.name; case "Union": // Handle nullable types if (this.isNullableUnion(type)) { const nonNullType = this.getNonNullTypeFromUnion(type); if (nonNullType) { return this.typeToGoType(nonNullType, true); } } return "interface{}"; default: return "interface{}"; } } scalarToGoType(scalarName, supportNull) { const mapping = { string: supportNull ? "null.String" : "string", int32: supportNull ? "null.Int" : "int32", int64: supportNull ? "null.Int" : "int64", float32: supportNull ? "null.Float" : "float32", float64: supportNull ? "null.Float" : "float64", boolean: supportNull ? "null.Bool" : "bool", utcDateTime: supportNull ? "null.Time" : "time.Time", }; return mapping[scalarName] || (supportNull ? "null.String" : "string"); } isNullableUnion(type) { if (type.kind !== "Union") return false; // Check if union contains null/void type for (const variant of type.variants.values()) { if (variant.type.kind === "Intrinsic" && (variant.type.name === "null" || variant.type.name === "void")) { return true; } } return false; } getNonNullTypeFromUnion(type) { if (type.kind !== "Union") return null; // Return the non-null type from union for (const variant of type.variants.values()) { if (variant.type.kind !== "Intrinsic" || (variant.type.name !== "null" && variant.type.name !== "void")) { return variant.type; } } return null; } toPascalCase(str) { return str .split("_") .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(""); } getAuthenticationInfo(operation) { // Check decorators on the operation for @useAuth if (operation.decorators) { for (const decorator of operation.decorators) { if (decorator.decorator && (decorator.decorator.name === "useAuth" || decorator.decorator.name === "$useAuth")) { // Get the auth type from decorator arguments if (decorator.args && decorator.args.length > 0) { const authArg = decorator.args[0]; // Extract auth type name from argument value if (authArg.value && authArg.value.name) { const authName = authArg.value.name; switch (authName) { case "BearerAuth": return "Required Bearer Token authentication"; case "BasicAuth": return "Required Basic authentication"; case "ApiKeyAuth": return "Required API Key authentication"; default: return `Required ${authName} authentication`; } } } return "Required authentication"; } } } return null; } getParameterComments(operation) { const comments = []; // Check for path parameters const pathParams = this.getPathParameters(operation); if (pathParams.length > 0) { comments.push(`// Path parameters:\n`); for (const param of pathParams) { const paramDoc = getDoc(this.program, param.property); comments.push(`// - ${param.name}: ${paramDoc || "path parameter"}\n`); } } // Check for query parameters const queryParams = this.getQueryParameters(operation); if (queryParams.length > 0) { comments.push(`// Query parameters:\n`); for (const param of queryParams) { const paramDoc = getDoc(this.program, param.property); comments.push(`// - ${param.name}: ${paramDoc || "query parameter"}\n`); } } return comments; } generateParameterVariables(operation) { const variables = []; // Generate path parameter variables (commented) const pathParams = this.getPathParameters(operation); for (const param of pathParams) { const goType = this.typeToGoType(param.property.type, false); variables.push(`\t// ${param.name} := c.Param("${param.name}") // ${goType}\n`); } // Generate query parameter variables (commented) const queryParams = this.getQueryParameters(operation); for (const param of queryParams) { const goType = this.typeToGoType(param.property.type, false); variables.push(`\t// ${param.name} := c.DefaultQuery("${param.name}", "") // ${goType}\n`); } if (variables.length > 0) { variables.push(`\n`); } return variables; } generateRequestStructInline(operation) { const bodyParam = this.getBodyParameter(operation); if (!bodyParam) { return null; } const bodyType = bodyParam.property.type; if (bodyType.kind !== "Model") { return null; } const isMultipart = this.isMultipartRequest(operation); let code = `\t// var request struct {\n`; // Generate struct fields for (const [propName, prop] of bodyType.properties) { code += this.generateInlineStructField(prop, isMultipart, true); } code += `\t// }\n\n`; return code; } generateInlineStructField(prop, isMultipart, isCommented = false) { const fieldName = this.toPascalCase(prop.name); const isNullable = prop.optional || this.isPropertyNullable(prop); const commentPrefix = isCommented ? "\t// " : "\t\t"; // Handle array of nested models specially if (prop.type.kind === "Model" && prop.type.name === "Array" && prop.type.indexer) { const elementType = prop.type.indexer.value; if (elementType.kind === "Model" && elementType.properties) { // Generate inline anonymous struct for array elements let structCode = `${commentPrefix}${fieldName} []struct {\n`; // Generate fields for the nested struct for (const [nestedPropName, nestedProp] of elementType.properties) { const nestedFieldName = this.toPascalCase(nestedPropName); const nestedIsNullable = nestedProp.optional || this.isPropertyNullable(nestedProp); const nestedGoType = this.typeToGoRequestType(nestedProp.type, nestedIsNullable); // Build tags for nested field const jsonTag = `json:"${nestedPropName}"`; const formTag = isMultipart ? ` form:"${nestedPropName}"` : ""; const validationTags = this.buildValidationTagsDetailed(nestedProp); const bindingTag = validationTags ? ` binding:"${validationTags}"` : ""; const allTags = `\`${jsonTag}${formTag}${bindingTag}\``; structCode += `${commentPrefix}\t${nestedFieldName} ${nestedGoType} ${allTags}\n`; } // Build validation tags for the array field const validationTags = this.buildValidationTagsDetailed(prop); const arrayJsonTag = `json:"${prop.name}"`; const arrayFormTag = isMultipart ? ` form:"${prop.name}"` : ""; const arrayBindingTag = validationTags ? ` binding:"${validationTags}"` : ""; const arrayTags = `\`${arrayJsonTag}${arrayFormTag}${arrayBindingTag}\``; structCode += `${commentPrefix}} ${arrayTags}\n`; return structCode; } } // Handle regular fields const goType = this.typeToGoRequestType(prop.type, isNullable); // Build tags const jsonTag = `json:"${prop.name}"`; const formTag = isMultipart ? ` form:"${prop.name}"` : ""; // Build validation tags const validationTags = this.buildValidationTagsDetailed(prop); const bindingTag = validationTags ? ` binding:"${validationTags}"` : ""; const allTags = `\`${jsonTag}${formTag}${bindingTag}\``; return `${commentPrefix}${fieldName} ${goType} ${allTags}\n`; } buildValidationTagsDetailed(prop) { const tags = []; // Check if required (non-optional) if (!prop.optional) { tags.push("required"); } // Check for decorators on the property if (prop.decorators) { for (const decorator of prop.decorators) { const decoratorName = decorator.decorator?.name; switch (decoratorName) { case "minLength": case "$minLength": if (decorator.args && decorator.args.length > 0) { // For now, just add a fixed value until we figure out the structure tags.push(`min=11`); } break; case "maxLength": case "$maxLength": if (decorator.args && decorator.args.length > 0) { // For now, just add a fixed value until we figure out the structure tags.push(`max=100`); } break; case "minItems": case "$minItems": if (decorator.args && decorator.args.length > 0) { // For now, just add a fixed value until we figure out the structure tags.push(`min=1`); } break; case "maxItems": case "$maxItems": if (decorator.args && decorator.args.length > 0) { // For now, just add a fixed value until we figure out the structure tags.push(`max=10`); } break; } } } // Handle nested struct validation for arrays if (prop.type.kind === "Model" && prop.type.name === "Array") { tags.push("dive"); if (!prop.optional) { tags.push("required"); } } return tags.join(","); } typeToGoRequestType(type, isNullable) { switch (type.kind) { case "Scalar": return this.scalarToGoRequestType(type.name, isNullable); case "Model": if (type.name === "Array" && type.indexer) { const elementType = this.typeToGoRequestType(type.indexer.value, false); return `[]${elementType}`; } // For model types that can be null, use pointer if (isNullable) { return `*${type.name}`; } return type.name; case "Union": // Handle nullable types if (this.isNullableUnion(type)) { const nonNullType = this.getNonNullTypeFromUnion(type); if (nonNullType) { return this.typeToGoRequestType(nonNullType, true); } } return "interface{}"; default: return "interface{}"; } } scalarToGoRequestType(scalarName, isNullable) { if (isNullable) { const mapping = { string: "null.String", int32: "null.Int", int64: "null.Int", float32: "null.Float", float64: "null.Float", boolean: "null.Bool", utcDateTime: "null.Time", }; return mapping[scalarName] || "null.String"; } else { const mapping = { string: "string", int32: "int32", int64: "int64", float32: "float32", float64: "float64", boolean: "bool", utcDateTime: "time.Time", }; return mapping[scalarName] || "string"; } } getPathParameters(operation) { const pathParams = []; if (operation.parameters) { for (const [paramName, param] of operation.parameters.properties) { // Check if parameter has @path decorator (looking for $path) if (param.decorators) { for (const decorator of param.decorators) { if (decorator.decorator?.name === "$path" || decorator.decorator?.name === "path") { pathParams.push({ name: paramName, property: param }); break; } } } } } return pathParams; } getQueryParameters(operation) { const queryParams = []; if (operation.parameters) { for (const [paramName, param] of operation.parameters.properties) { // Check if parameter has @query decorator (looking for $query) if (param.decorators) { for (const decorator of param.decorators) { if (decorator.decorator?.name === "$query" || decorator.decorator?.name === "query") { queryParams.push({ name: paramName, property: param }); break; } } } } } return queryParams; } getBodyParameter(operation) { if (operation.parameters) { for (const [paramName, param] of operation.parameters.properties) { // Check if parameter has @body decorator (looking for $body) if (param.decorators) { for (const decorator of param.decorators) { if (decorator.decorator?.name === "$body" || decorator.decorator?.name === "body") { return { name: paramName, property: param }; } } } } } return null; } isMultipartRequest(operation) { // Check if operation has multipart content type // This is a simplified check - you might want to enhance this // based on your TypeSpec schema or decorators return false; // Default to false, enhance as needed } } // Export decorators export { $domainGinHandlerGen, $domainGinHandlerName, getDomainGinHandlerName, isDomainGinHandlerGen, } from "./decorators.js"; //# sourceMappingURL=index.js.map