@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
JavaScript
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