diff --git a/rest/src/main/resources/scripts/GoApiTemplate.groovy b/rest/src/main/resources/scripts/GoApiTemplate.groovy index 31079a2b4e7..c52cb1a10f4 100644 --- a/rest/src/main/resources/scripts/GoApiTemplate.groovy +++ b/rest/src/main/resources/scripts/GoApiTemplate.groovy @@ -1,113 +1,1477 @@ package scripts -import org.apache.commons.lang.StringUtils -import org.zstack.header.identity.SuppressCredentialCheck +import org.zstack.header.query.APIQueryMessage import org.zstack.header.rest.RestRequest import org.zstack.rest.sdk.SdkFile import org.zstack.rest.sdk.SdkTemplate +import org.zstack.utils.Utils +import org.zstack.utils.logging.CLogger +import org.zstack.header.rest.RestResponse +import java.lang.reflect.Field +import java.lang.reflect.ParameterizedType +import java.lang.reflect.Type - +/** + * Go SDK API Template Generator + */ class GoApiTemplate implements SdkTemplate { + private static final CLogger logger = Utils.getLogger(GoApiTemplate.class) private Class apiMsgClazz private RestRequest at private String path private Class responseClass + private String allTo; private String replyName private SdkTemplate inventoryGenerator + private String actionType + private String resourceName + private String clzName + + // For Query APIs, store the inventory class + private Class queryInventoryClass + // Store the field name of the inventory in the response class (e.g. "inventory", "vmInventory") + private String inventoryFieldName + + // Track if different parts of the API have been grouped into consolidated files + boolean isActionGrouped = false + boolean isViewGrouped = false + boolean isParamGrouped = false + + // Track generated files to avoid duplicates + private static Set generatedParamFiles = new HashSet<>() + private static Set generatedActionFiles = new HashSet<>() + private static Set generatedViewFiles = new HashSet<>() + + // Track known inventory classes for validation + private static Set> knownInventoryClasses = null + + // Track APIs that have been grouped by GoInventory to avoid individual file generation + static Set groupedApiNames = new HashSet<>() + + // LongJob mappings (provided by GoInventory) + private static Map longJobMappings = new HashMap<>() + + // Track APIs that should be skipped during generation + private static Set skippedApis = new HashSet<>() + GoApiTemplate(Class apiMsgClass, SdkTemplate inventoryGenerator) { - apiMsgClazz = apiMsgClass - this.inventoryGenerator = inventoryGenerator - at = apiMsgClazz.getAnnotation(RestRequest.class) - if (at.path() == "null") { - path = at.optionalPaths()[0] - }else { - path = at.path() + try { + apiMsgClazz = apiMsgClass + this.inventoryGenerator = inventoryGenerator + at = apiMsgClazz.getAnnotation(RestRequest.class) + if (at == null) { + logger.warn("[GoSDK] Class ${apiMsgClazz.name} is missing @RestRequest annotation") + return + } + + String rawPath = at.path() + if (rawPath == null || rawPath == "null") { + String[] opts = at.optionalPaths() + if (opts != null && opts.length > 0) { + path = opts[0] + } else { + logger.warn("[GoSDK] API ${apiMsgClazz.name} has no path or optionalPaths") + path = "/unknown" + } + } else { + path = rawPath + } + + responseClass = at.responseClass() + if (responseClass == null || responseClass == org.zstack.header.rest.RestResponse.class) { + org.zstack.header.rest.RestResponse res = apiMsgClazz.getAnnotation(org.zstack.header.rest.RestResponse.class) + if (res != null) { + responseClass = res.value() + } + } + allTo = "" + if (responseClass != null) { + replyName = responseClass.simpleName.replaceAll('^API', '').replaceAll('Reply$', '').replaceAll('Event$', '') + RestResponse restResponse = responseClass.getAnnotation(RestResponse) + allTo = restResponse != null ? restResponse.allTo() : "" + } else { + logger.warn("[GoSDK] Could not determine responseClass for " + apiMsgClazz.name) + replyName = "UnknownResponse" + } + + // Only strip API prefix and Msg suffix, keep Action to avoid name collisions + // e.g. APIQueryMonitorTriggerActionMsg -> QueryMonitorTriggerAction + clzName = apiMsgClazz.simpleName.replaceAll('^API', '').replaceAll('Msg$', '') + + parseActionAndResource() + logger.warn("[GoSDK] Parsed API: ${apiMsgClazz.simpleName} -> Action=${actionType}, Resource=${resourceName}") + + // Find the inventory class if available (for both Query and Action APIs) + queryInventoryClass = findInventoryClass() + + logger.warn("[GoSDK] Processing API: " + clzName + " -> action=" + actionType + ", resource=" + resourceName + ", response=" + responseClass?.simpleName) + } catch (Throwable e) { + logger.error("[GoSDK] CRITICAL ERROR constructing GoApiTemplate for ${apiMsgClass.name}: ${e.class.name}: ${e.message}", e) + throw e } - responseClass = at.responseClass() - replyName = StringUtils.removeStart(responseClass.simpleName, "API") } - @Override + RestRequest getAt() { + return at + } + + String getActionType() { + return actionType + } + + String getResourceName() { + return resourceName + } + + Class getQueryInventoryClass() { + return queryInventoryClass + } + + Class getResponseClass() { + return responseClass + } + + /** + * Return all client method names this template may emit, for deduplication. + */ + Set getGeneratedMethodNames() { + def names = new LinkedHashSet() + names.add(clzName) + + // Query APIs always generate both Get and Page helpers + if (isQueryMessage()) { + String getMethodName = clzName.replaceFirst('^Query', 'Get') + names.add(getMethodName) + + String pageMethodName = clzName.replaceFirst('^Query', 'Page') + names.add(pageMethodName) + } + + if (supportsAsync() && shouldGenerateAsync()) { + names.add("${clzName}Async") + } + + return names + } + + boolean isQueryMessage() { + return APIQueryMessage.class.isAssignableFrom(apiMsgClazz) + } + + Class getApiMsgClazz() { + return apiMsgClazz + } + + String getParamStructName() { + return clzName + "Param" + } + + String getDetailParamStructName() { + return clzName + "ParamDetail" + } + + /** + * Set known inventory classes for validation + */ + static void setKnownInventoryClasses(Set> inventories) { + knownInventoryClasses = inventories + logger.warn("[GoSDK] Registered " + (inventories?.size() ?: 0) + " inventory classes") + } + + /** + * Set LongJob mappings (called by GoInventory) + */ + static void setLongJobMappings(Map mappings) { + longJobMappings = mappings + logger.warn("[GoSDK] Registered ${mappings.size()} LongJob mappings") + } + + /** + * Check if current API supports async operation + */ + private boolean supportsAsync() { + if (apiMsgClazz == null) return false + return longJobMappings.containsKey(apiMsgClazz) + } + + /** + * Get list of skipped APIs + */ + static Set getSkippedApis() { + return skippedApis + } + + /** + * Check if a view class is available + */ + private boolean isViewAvailable(Class viewClass) { + if (viewClass == null) { + return false + } + // Check if it's an Inventory class or a Reply class + if (knownInventoryClasses != null && knownInventoryClasses.contains(viewClass)) { + return true + } + // Reply classes should always be available as we generate them + if (viewClass.simpleName.endsWith("Reply") || viewClass.simpleName.endsWith("Event")) { + return true + } + return false + } + + /** + * Check if API class has valid parameter fields (excluding inherited base fields) + */ + private boolean hasApiParams() { + if (apiMsgClazz == null) { + return false + } + try { + def fields = apiMsgClazz.getDeclaredFields() + // Filter out synthetic, static fields and common inherited fields + def validFields = fields.findAll { field -> + !java.lang.reflect.Modifier.isStatic(field.modifiers) && + !field.synthetic && + !field.name.startsWith('__') && + field.name != 'session' && + field.name != 'timeout' && + field.name != 'commandTimeout' + } + boolean result = validFields.size() > 0 + if (!result) { + logger.warn("[GoSDK] ${apiMsgClazz.simpleName} has NO valid parameter fields") + } + return result + } catch (Exception e) { + logger.warn("[GoSDK] Error checking params for ${apiMsgClazz.simpleName}: ${e.message}") + return true // Default to true if can't determine + } + } + + private Class findInventoryClass() { + inventoryFieldName = null + if (responseClass == null) return null + + logger.debug("[GoSDK] Finding inventory class for API: " + clzName + " (response=" + responseClass?.simpleName + ")") + + try { + // 1. Try to find 'inventory' field (for single resource return) + try { + Field inventoryField = responseClass.getDeclaredField("inventory") + if (inventoryField != null) { + Class fieldType = inventoryField.getType() + // If inventory is a collection or map, skip unwrap to avoid List/MapView pointer mismatch + if (Collection.class.isAssignableFrom(fieldType) || Map.class.isAssignableFrom(fieldType)) { + logger.warn("[GoSDK] Inventory field for " + clzName + " is a collection/map, skip unwrap") + inventoryFieldName = null + // Try to unwrap generic element/value only if it's a concrete Inventory class + if (inventoryField.getGenericType() instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType) inventoryField.getGenericType() + Type[] args = pt.getActualTypeArguments() + if (args != null && args.length > 0) { + Type candidate = args.length > 1 ? args[1] : args[0] + if (candidate instanceof Class && ((Class) candidate).isAnnotationPresent(org.zstack.header.search.Inventory.class)) { + return (Class) candidate + } + } + } + return null + } + logger.warn("[GoSDK] Found inventory field for " + clzName + ": " + fieldType.simpleName) + inventoryFieldName = "inventory" + return fieldType + } + } catch (NoSuchFieldException e) { + // ignore + } + + // 2. Try to find 'inventories' field (for query/list return) + try { + Field inventoriesField = responseClass.getDeclaredField("inventories") + if (inventoriesField != null) { + def genericType = inventoriesField.getGenericType() + if (genericType != null && genericType instanceof ParameterizedType) { + def paramType = (ParameterizedType) genericType + def actualTypes = paramType.getActualTypeArguments() + if (actualTypes != null && actualTypes.length > 0) { + def typeArg = actualTypes[0] + if (typeArg instanceof Class) { + logger.warn("[GoSDK] Found inventories element class for " + clzName + ": " + ((Class) typeArg).simpleName) + return (Class) typeArg + } + } + } + } + } catch (NoSuchFieldException e) { + // ignore + } + } catch (NoSuchFieldException e) { + logger.debug("[GoSDK] No 'inventories' field in " + responseClass?.simpleName) + } catch (Exception e) { + logger.warn("[GoSDK] Error finding inventory class for " + clzName + ": " + e.message) + } + + // Fallback: try to find in known inventories by name + if (resourceName != null && !resourceName.isEmpty() && knownInventoryClasses != null) { + String expectedInventoryName = resourceName + "Inventory" + for (Class inv : knownInventoryClasses) { + if (inv.simpleName == expectedInventoryName) { + logger.warn("[GoSDK] Found inventory by name matching for " + clzName + ": " + inv.simpleName) + return inv + } + } + } + + logger.debug("[GoSDK] Could not find inventory class for API: " + clzName) + return null + } + + private void parseActionAndResource() { + def actionPrefixes = ["Create", "Query", "Get", "Update", "Delete", "Destroy", + "Start", "Stop", "Reboot", "Attach", "Detach", "Change", + "Expunge", "Recover", "Migrate", "Clone", "Resize", "Add", "Remove", + "Allocate", "Apply", "Release", "Deallocate", "Validate", "Sync", "Reconnect", + "Set", "Reset", "Search", "Calculate", "Check", "Refresh", + "Batch", "Login", "Logout", "Register", "Unregister", "Security", "Ack", "Clean", "Bootstrap", "Inspect"] + + for (String prefix : actionPrefixes) { + if (clzName.startsWith(prefix)) { + actionType = prefix + resourceName = clzName.substring(prefix.length()) + + // Further clean resourceName + resourceName = resourceName.replaceAll('Action$', '').replaceAll('Msg$', '') + return + } + } + + // Extended prefixes for better grouping + def extendedPrefixes = ["SNS", "Sns", "Resume", "Migrate", "Locate", "Generate", "Export", "SelfTest", + "Calculate", "Check", "Refresh", "Sync", "Reconnect", "Archive", "Backup", "Revert"] + for (String prefix : extendedPrefixes) { + if (clzName.startsWith(prefix)) { + actionType = prefix + resourceName = clzName.substring(prefix.length()) + resourceName = resourceName.replaceAll('Action$', '').replaceAll('Msg$', '') + return + } + } + + actionType = "" + resourceName = clzName.replaceAll('Action$', '').replaceAll('Msg$', '') + } + + private String toSnakeCase(String name) { + if (name == null || name.isEmpty()) { + return "unknown" + } + return name.replaceAll('([a-z])([A-Z])', '$1_$2').toLowerCase() + } + + private String getApiPath() { + if (path.startsWith("/")) { + return "v1" + path + } + return "v1/" + path + } + + private String getApiOptPath() { + String[] opts = at.optionalPaths() + if (opts.length == 0) { + return "" + } + String optPath = opts[0] + if (optPath.startsWith("/")) { + return "v1" + optPath + } + return "v1/" + optPath + } + List generate() { - return generateAction(StringUtils.removeEnd(StringUtils.removeStart(apiMsgClazz.simpleName, "API"), "Msg")) + return [] + } + + /** + * Generate code for the response view struct (Event or Reply). + */ + String generateResponseViewCode() { + if (responseClass == null) return "" + + String viewStructName = inventoryGenerator.getViewStructName(responseClass) + return inventoryGenerator.generateViewStruct(responseClass, viewStructName) + } + + private String generateMethodImplementation(String apiPath, String httpMethod, String viewStructName, boolean isQueryMessage, Set skipNames) { + def builder = new StringBuilder() + boolean skipMain = skipNames?.contains(clzName) + + builder.append("// ${clzName} ${getMethodDescription()}\n") + + // http_client unwraps inventory fields for POST/PUT requests + // Therefore Create/Add/Update/Change calls can return the Inventory View directly + // Only GET calls may need manual unwrapping + boolean unwrapForGet = !isQueryMessage && + queryInventoryClass != null && + viewStructName == inventoryGenerator.getViewStructName(queryInventoryClass) && + responseClass != queryInventoryClass && + inventoryFieldName != null && + (actionType == "Get" || httpMethod == "GET") + + String responseStructName = inventoryGenerator.getViewStructName(responseClass) + String goInventoryFieldName = inventoryFieldName != null ? inventoryFieldName.substring(0, 1).toUpperCase() + inventoryFieldName.substring(1) : "Inventory" + + if (isQueryMessage) { + // Query APIs generate a list method + if (!skipMain) { + builder.append(generateQueryMethod(apiPath, viewStructName)) + } + + // Always generate Get(single) for Query APIs to avoid compile gaps + String getMethodName = clzName.replaceFirst('^Query', 'Get') + String optPath = getApiOptPath() + // Emit unless explicitly skipped (no clzName equality check) + if (optPath != "" && !skipNames.contains(getMethodName)) { + builder.append(generateGetMethodForQuery(optPath, viewStructName)) + } + + // Generate Page pagination helper for Query APIs + String pageMethodName = clzName.replaceFirst('^Query', 'Page') + if (!skipNames.contains(pageMethodName)) { + builder.append(generatePageMethod(apiPath, viewStructName)) + } + } else { + if (!skipMain) { + // CRITICAL: Prioritize @RestRequest.method over actionType derived from class name + // This fixes issues like APIGetVersionMsg (actionType="Get") with method=PUT + + // First check HTTP method from annotation, then fall back to actionType-based logic + if (httpMethod == "POST") { + // POST operations (Create/Add) + builder.append(generateCreateMethod(apiPath, viewStructName, false, responseStructName, goInventoryFieldName)) + } else if (httpMethod == "GET") { + // GET operations (Get/Query) + builder.append(generateGetMethod(apiPath, viewStructName, unwrapForGet, responseStructName, goInventoryFieldName)) + } else if (httpMethod == "PUT") { + // PUT operations (Update/Change/Action) + // Special case: Expunge actions return void + if (actionType == "Expunge") { + builder.append(generateExpungeMethod(apiPath)) + } else { + builder.append(generateUpdateMethod(apiPath, viewStructName, false, responseStructName, goInventoryFieldName)) + } + } else if (httpMethod == "DELETE") { + // DELETE operations + builder.append(generateDeleteMethod(apiPath)) + } else { + // Fallback for unknown HTTP methods (should rarely happen) + logger.warn("[GoSDK] Unknown HTTP method ${httpMethod} for ${clzName}, using generic method") + boolean unwrapGeneric = (httpMethod == "GET") ? unwrapForGet : false + builder.append(generateGenericMethod(apiPath, httpMethod, viewStructName, unwrapGeneric, responseStructName, goInventoryFieldName)) + } + } + + String asyncMethodName = "${clzName}Async" + if (supportsAsync() && shouldGenerateAsync() && !skipNames.contains(asyncMethodName)) { + builder.append(generateAsyncMethod(apiPath, httpMethod, viewStructName)) + } + } + + return builder.toString() + } + + /** + * Public method to generate just the method implementation code. + * Used by GoInventory to consolidate "Other" actions. + */ + String generateMethodCode() { + return generateMethodCode(Collections.emptySet()) } - def generateAction(String clzName) { - def f = new SdkFile() - def file = [] - f.subPath = "/api/" - f.fileName = "${clzName}.go" - f.content = """package api + /** + * Generate client method code while skipping provided method names. + */ + String generateMethodCode(Set skipNames) { + String apiPath = getApiPath() + String httpMethod = at.method().toString() + boolean isQuery = isQueryMessage() -import ( - "encoding/json" - mapstruct "github.com/mitchellh/mapstructure" - log "github.com/sirupsen/logrus" -) + Class targetViewClass = queryInventoryClass != null ? queryInventoryClass : responseClass + String viewStructName = inventoryGenerator.getViewStructName(targetViewClass) -const ( - ${clzName}Path = "${path}" - ${clzName}Method = "${at.method()}" - ${clzName}SuppressCredentialCheck = ${apiMsgClazz.isAnnotationPresent(SuppressCredentialCheck.class)} -) + return generateMethodImplementation(apiPath, httpMethod, viewStructName, isQuery, skipNames ?: Collections.emptySet()) + } + + private String getMethodDescription() { + switch (actionType) { + case "Create": return "creates ${resourceName}" + case "Query": return "queries ${resourceName} list" + case "Get": return "gets ${resourceName} by uuid" + case "Update": return "updates ${resourceName}" + case "Delete": return "deletes ${resourceName}" + case "Destroy": return "destroys ${resourceName}" + case "Start": return "starts ${resourceName}" + case "Stop": return "stops ${resourceName}" + case "Change": return "changes ${resourceName}" + case "Add": return "adds ${resourceName}" + case "Remove": return "removes ${resourceName}" + default: return "operates on ${resourceName}" + } + } -${inventoryGenerator.generateStruct(apiMsgClazz, clzName + "Struct")} + private String generateCreateMethod(String apiPath, String viewStructName, boolean unwrap, String responseStructName, String fieldName) { + boolean hasParams = hasApiParams() -${StringUtils.removeEnd(inventoryGenerator.generateStruct(responseClass, clzName + "Rsp"),"}\n\n")} - Error ErrorCode `json:"error"` + if (!hasParams) { + // No params: don't require user to pass params, use empty map internally + if (unwrap) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Post(ctx, "${apiPath}", map[string]interface{}{}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil } +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.Post(ctx, "${apiPath}", map[string]interface{}{}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } -type ${clzName}Req struct { - ${clzName} ${clzName}Struct `json:"${at.isAction() ? StringUtils.uncapitalize(clzName) : at.parameterName()}"` + // Has params: require user to pass params + if (unwrap) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Post(ctx, "${apiPath}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil } +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.Post(ctx, "${apiPath}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } -func (rsp *${clzName}Rsp) getErrorCode() ErrorCode { - return rsp.Error + private String generateQueryMethod(String apiPath, String viewStructName) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params *param.QueryParam) ([]view.${viewStructName}, error) { +\tvar resp []view.${viewStructName} +\treturn resp, cli.List(ctx, "${apiPath}", params, &resp) } +""" + } + + /** + * Generate Page pagination method for Query APIs. + * Derive PageXxx from the QueryXxx resource name. + */ + private String generatePageMethod(String apiPath, String viewStructName) { + String pageMethodName = clzName.replaceFirst('^Query', 'Page') + String varName = resourceName.substring(0, 1).toLowerCase() + resourceName.substring(1) + // Only replace y with ies if preceded by a consonant + if (varName.endsWith("y") && varName.length() > 1 && !"aeiou".contains(varName.charAt(varName.length() - 2).toString())) { + varName = varName.substring(0, varName.length() - 1) + "ies" + } else if (!varName.endsWith("s")) { + varName = varName + "s" + } -func (req *${clzName}Req) getMethod() string { - return ${clzName}Method + return """ +// ${pageMethodName} Pagination +func (cli *ZSClient) ${pageMethodName}(ctx context.Context, params *param.QueryParam) ([]view.${viewStructName}, int, error) { +\tvar ${varName} []view.${viewStructName} +\ttotal, err := cli.Page(ctx, "${apiPath}", params, &${varName}) +\treturn ${varName}, total, err } +""" + } + + /** + * Generate Get(uuid) single-resource method for Query APIs. + * Extract the resource portion from Query{Resource}. + */ + private String generateGetMethodForQuery(String apiPath, String viewStructName) { + // Extract {Resource} from Query{Resource} + String getMethodName = clzName.replaceFirst("^Query", "Get") + + // Extract URL placeholders to see if special handling is required + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // If the path has multiple placeholders (for example {category}/{name}), use GetWithSpec + if (placeholders.size() >= 2) { + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String spec = buildSpecPath(remainingPlaceholders) -func (req *${clzName}Req) getUri() string { - return ${clzName}Path + return """ +func (cli *ZSClient) ${getMethodName}(ctx context.Context, ${params}) (*view.${viewStructName}, error) { +\tvar resp view.${viewStructName} +\terr := cli.GetWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "${allTo}", nil, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil } +""" + } -func (req *${clzName}Req) isSuppressCredentialCheck() bool { - return ${clzName}SuppressCredentialCheck + // Standard case: single uuid parameter + return """ +func (cli *ZSClient) ${getMethodName}(ctx context.Context, uuid string) (*view.${viewStructName}, error) { +\tvar resp view.${viewStructName} +\tif err := cli.Get(ctx, "${cleanPath}", uuid, nil, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil } +""" + } -func (sdk *ZStackClient) ${clzName}(param *${clzName}Struct) ${clzName}Rsp { - req := ${clzName}Req{${clzName}: *param} - rsp := ${clzName}Rsp{} - body, err := sdk.Call(&req) - if err != nil { - rsp.Error.Code = err - return rsp - } + private String generateGetMethod(String apiPath, String viewStructName, boolean unwrap, String responseStructName, String fieldName) { + // Extract URL placeholders + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) - result := make(map[string]interface{}) - err = json.Unmarshal(body, &result) - if err != nil { - log.Debugf("Unmarshal response failed, reason: %s", err) - rsp.Error.Code = err - return rsp - } + // Only use GetWithSpec when there are two or more placeholders + boolean useSpec = placeholders.size() >= 2 - err = mapstruct.Decode(result, &rsp) - if err != nil { - log.Debugf("decode %s failed, err: %v", result, err) - rsp.Error.Code = err - return rsp - } + if (unwrap) { + if (!useSpec) { + // Check if there are any placeholders + if (placeholders.size() == 0) { + // No placeholder: no uuid parameter needed + // Use GetWithRespKey to extract the inventory field + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.GetWithRespKey(ctx, "${cleanPath}", "", "${allTo}", nil, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } else { + // Single placeholder: use GetWithRespKey with uuid to extract inventory + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.GetWithRespKey(ctx, "${cleanPath}", uuid, "${allTo}", nil, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } else { + // Multiple placeholders: use GetWithSpec + // First placeholder is the resourceId; the rest form the spec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\terr := cli.GetWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "${allTo}", nil, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } else { + if (!useSpec) { + // Check if there are any placeholders + if (placeholders.size() == 0) { + // No placeholder: no uuid parameter needed + // Use GetWithRespKey with empty responseKey to parse whole response + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tvar resp view.${viewStructName} +\tif err := cli.GetWithRespKey(ctx, "${cleanPath}", "", "${allTo}", nil, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } else { + // Single placeholder: use GetWithRespKey with uuid + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string) (*view.${viewStructName}, error) { +\tvar resp view.${viewStructName} +\tif err := cli.GetWithRespKey(ctx, "${cleanPath}", uuid, "${allTo}", nil, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } else { + // Multiple placeholders: use GetWithSpec + // First placeholder is the resourceId; the rest form the spec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}) (*view.${viewStructName}, error) { +\tvar resp view.${viewStructName} +\terr := cli.GetWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "${allTo}", nil, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } + } + + private String generateUpdateMethod(String apiPath, String viewStructName, boolean unwrap, String responseStructName, String fieldName) { + // Extract URL placeholders + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // Determine whether this is an Action API (isAction=true or path ends with /actions) + boolean isActionApi = at.isAction() || apiPath.endsWith("/actions") + + // Only use PutWithSpec when there are two or more placeholders + boolean useSpec = placeholders.size() >= 2 + + // Check if API has parameters + boolean hasParams = hasApiParams() + + // Resolve the action key (map key for Action APIs) + // Prefer @RestRequest.parameterName, otherwise derive from class name + String actionKey + if (isActionApi) { + if (at.parameterName() != null && !at.parameterName().isEmpty() && !at.parameterName().equals("null")) { + actionKey = at.parameterName() + } else { + def apiClassName = apiMsgClazz.simpleName + def actionName = apiClassName.replaceAll('^API', '').replaceAll('Msg$', '') + actionKey = actionName.substring(0, 1).toLowerCase() + actionName.substring(1) + } + } + + if (unwrap) { + if (!useSpec) { + if (placeholders.size() == 1) { + // Single placeholder pulled from the path parameter + String paramName = toSafeGoParamName(placeholders[0]) + if (isActionApi) { + // Action APIs wrap params.Params inside a map + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", ${paramName}, map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", ${paramName}, params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } else { + // No placeholder: use the standard Put method + if (!hasParams) { + // No params: don't require user input + if (isActionApi) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", "", map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", "", map[string]interface{}{}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } else if (isActionApi) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", "", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\tif err := cli.Put(ctx, "${cleanPath}", uuid, params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } + } else { + // Multiple placeholders: use PutWithSpec + // First placeholder is the resourceId; the rest form the spec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp view.${responseStructName} +\terr := cli.PutWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "", params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\treturn &resp.${fieldName}, nil +} +""" + } + } else { + // Variables already declared at method level, no need to redeclare + + if (!useSpec) { + if (placeholders.size() == 1) { + // Single placeholder pulled from the path parameter + String paramName = toSafeGoParamName(placeholders[0]) + if (isActionApi) { + String actionSuffix = apiPath.endsWith("/actions") ? "actions" : "" + // Action APIs wrap params.Params inside a map + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithSpec(ctx, "${cleanPath}", ${paramName}, "${actionSuffix}", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithRespKey(ctx, "${cleanPath}", ${paramName}, "${allTo}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } else { + // No placeholder: use the standard Put method + if (!hasParams) { + // No params: don't require user input + if (isActionApi) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithRespKey(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithRespKey(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } else if (isActionApi) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithRespKey(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } else { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\tif err := cli.PutWithRespKey(ctx, "${cleanPath}", uuid, "${allTo}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } + } else { + // Multiple placeholders: use PutWithSpec + // First placeholder is the resourceId; the rest form the spec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tresp := view.${viewStructName}{} +\terr := cli.PutWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "${allTo}", params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\treturn &resp, nil +} +""" + } + } + } + + private String generateDeleteMethod(String apiPath) { + // Extract URL placeholders + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // Only use DeleteWithSpec when there are two or more placeholders + boolean useSpec = placeholders.size() >= 2 + + if (!useSpec) { + // Single or no placeholder: use the standard Delete method + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string, deleteMode param.DeleteMode) error { +\treturn cli.Delete(ctx, "${cleanPath}", uuid, string(deleteMode)) +} +""" + } else { + // Multiple placeholders: use DeleteWithSpec + // First placeholder is the resourceId; the rest form the spec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + String paramsStr = "fmt.Sprintf(\"deleteMode=%s\", deleteMode)" + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, deleteMode param.DeleteMode) error { +\treturn cli.DeleteWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, ${paramsStr}, nil) +} +""" + } + } + + /** + * Generate Expunge method. + * Expunge uses PUT at {resource}/{uuid}/actions and only returns an error. + */ + private String generateExpungeMethod(String apiPath) { + // Extract URL placeholders + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // Expunge API typically uses /resource/{uuid}/actions; strip the /actions suffix + if (cleanPath.endsWith("/actions")) { + cleanPath = cleanPath.substring(0, cleanPath.length() - 8) + } + + // Build parameter key, for example expungeImage + String paramKey = clzName.substring(0, 1).toLowerCase() + clzName.substring(1) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string) error { +\tparams := map[string]interface{}{ +\t\t"${paramKey}": map[string]interface{}{}, +\t} +\treturn cli.Put(ctx, "${cleanPath}", uuid, params, nil) +} +""" + } + + private String generateGenericMethod(String apiPath, String httpMethod, String viewStructName, boolean unwrap, String responseStructName, String fieldName) { + String respType = unwrap ? "view.${responseStructName}" : "view.${viewStructName}" + String returnStmt = unwrap ? "return &resp.${fieldName}, nil" : "return &resp, nil" + String respDecl = unwrap ? "var resp ${respType}" : "resp := ${respType}{}" + + // Extract URL placeholders + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // Determine whether this is an Action API (isAction=true or path ends with /actions) + boolean isActionApi = at.isAction() || apiPath.endsWith("/actions") + + // Resolve the action key (map key for Action APIs) + // Prefer @RestRequest.parameterName, otherwise derive from class name + String actionKey + if (isActionApi) { + if (at.parameterName() != null && !at.parameterName().isEmpty() && !at.parameterName().equals("null")) { + actionKey = at.parameterName() + } else { + def apiClassName = apiMsgClazz.simpleName + def actionName = apiClassName.replaceAll('^API', '').replaceAll('Msg$', '') + actionKey = actionName.substring(0, 1).toLowerCase() + actionName.substring(1) + } + } + + // Only use *WithSpec helpers when there are two or more placeholders + boolean useSpec = placeholders.size() >= 2 + + switch (httpMethod) { + case "GET": + if (!useSpec) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp ${respType} +\tif err := cli.Get(ctx, "${cleanPath}", "", params, &resp); err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String pathSpec = buildPathSpec(placeholders) + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\tvar resp ${respType} +\terr := cli.GetWithSpec(ctx, "${cleanPath}", ${pathSpec}, "", "${allTo}", params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + case "POST": + if (!useSpec) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := cli.Post(ctx, "${cleanPath}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + // POST lacks *WithSpec helpers; build the full URL manually + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String fullPath = buildFullPath(placeholders) + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\terr := cli.Post(ctx, ${fullPath}, params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + case "PUT": + boolean hasParams = hasApiParams() + if (!useSpec) { + if (placeholders.size() == 1) { + // Single placeholder pulled from the path parameter + String paramName = toSafeGoParamName(placeholders[0]) + if (!hasParams) { + // No params case + if (isActionApi) { + String actionSuffix = apiPath.endsWith("/actions") ? "actions" : "" + String putMethod = unwrap ? "cli.Put" : "cli.PutWithSpec" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", ${paramName}, map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp)""" : + """(ctx, "${cleanPath}", ${paramName}, "${actionSuffix}", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", ${paramName}, map[string]interface{}{}, &resp)""" : + """(ctx, "${cleanPath}", ${paramName}, "${allTo}", map[string]interface{}{}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + } else if (isActionApi) { + // Action APIs wrap params.Params inside a map + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", ${paramName}, map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp)""" : + """(ctx, "${cleanPath}", ${paramName}, "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", ${paramName}, params, &resp)""" : + """(ctx, "${cleanPath}", ${paramName}, "${allTo}", params, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${paramName} string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + } else { + // No placeholder: use the standard Put method + if (!hasParams) { + // No params: don't require user input + if (isActionApi) { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", "", map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp)""" : + """(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": map[string]interface{}{}, +\t}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", "", map[string]interface{}{}, &resp)""" : + """(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + } else if (isActionApi) { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", "", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp)""" : + """(ctx, "${cleanPath}", "", "${allTo}", map[string]interface{}{ +\t\t"${actionKey}": params.Params, +\t}, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + String putMethod = unwrap ? "cli.Put" : "cli.PutWithRespKey" + String putArgs = unwrap ? + """(ctx, "${cleanPath}", uuid, params, &resp)""" : + """(ctx, "${cleanPath}", uuid, "${allTo}", params, &resp)""" + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := ${putMethod}${putArgs}; err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + } + } else { + // Multiple placeholders: use PutWithSpec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\terr := cli.PutWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "${allTo}", params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + case "DELETE": + if (!useSpec) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, uuid string, deleteMode param.DeleteMode) error { +\treturn cli.Delete(ctx, "${cleanPath}", uuid, string(deleteMode)) +} +""" + } else { + // Multiple placeholders: use DeleteWithSpec + String firstParam = toSafeGoParamName(placeholders[0]) + def remainingPlaceholders = placeholders.drop(1) + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String spec = buildSpecPath(remainingPlaceholders) + String paramsStr = "fmt.Sprintf(\"deleteMode=%s\", deleteMode)" - return rsp + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, deleteMode param.DeleteMode) error { + return cli.DeleteWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, ${paramsStr}, nil) } +""" + } + default: + if (!useSpec) { + return """func (cli *ZSClient) ${clzName}(ctx context.Context, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\tif err := cli.Post(ctx, "${cleanPath}", params, &resp); err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } else { + // POST lacks *WithSpec helpers; build the full URL manually + String params = placeholders.collect { "${toSafeGoParamName(it)} string" }.join(", ") + String fullPath = buildFullPath(placeholders) + return """func (cli *ZSClient) ${clzName}(ctx context.Context, ${params}, params param.${clzName}Param) (*view.${viewStructName}, error) { +\t${respDecl} +\terr := cli.Post(ctx, ${fullPath}, params, &resp) +\tif err != nil { +\t\treturn nil, err +\t} +\t${returnStmt} +} +""" + } + } + } + + /** + * Decide whether an async method should be generated. + */ + private boolean shouldGenerateAsync() { + // Skip async generation for Query/Get/Delete operations + if (actionType in ["Query", "Get", "Delete", "Destroy", "Remove", "Expunge"]) { + return false + } + return true + } + + /** + * Generate async helper returning LongJob UUID for status polling. + */ + private String generateAsyncMethod(String apiPath, String httpMethod, String viewStructName) { + if (httpMethod != "POST") { + logger.warn("[GoSDK] Skip async generation for non-POST API ${clzName}: ${httpMethod}") + return "" + } + String asyncMethodName = "${clzName}Async" + String resource = apiPath.replaceAll(/^v1\/?/, "") + + def builder = new StringBuilder() + builder.append("\n// ${asyncMethodName} Async\n") + builder.append("func (cli *ZSClient) ${asyncMethodName}(ctx context.Context, params param.${clzName}Param) (string, error) {\n") + builder.append("\n") + builder.append("\tresource := \"${resource}\"\n") + builder.append("\tresponseKey := \"\"\n") + builder.append("\tvar retVal interface{}\n") + builder.append("\n") + builder.append("\tapiId, err := cli.PostWithAsync(ctx, resource, responseKey, params, retVal, true)\n") + builder.append("\tif err != nil {\n") + builder.append("\t\treturn \"\", err\n") + builder.append("\t}\n") + builder.append("\n") + builder.append("\treturn apiId, nil\n") + builder.append("}\n") + + return builder.toString() + } + + /** + * Extract placeholders from a URL path. + * Example: "/l3-networks/{l3NetworkUuid}/ip/{ip}/availability" -> ["l3NetworkUuid", "ip"] + */ + private List extractUrlPlaceholders(String apiPath) { + def placeholders = [] + def matcher = (apiPath =~ /\{([^}]+)\}/) + while (matcher.find()) { + placeholders.add(matcher.group(1)) + } + return placeholders + } + + /** + * Convert placeholder names to safe Go parameter names (avoid keyword clashes). + */ + private String toSafeGoParamName(String name) { + // Go keyword list + def goKeywords = ["break", "case", "chan", "const", "continue", "default", "defer", + "else", "fallthrough", "for", "func", "go", "goto", "if", "import", + "interface", "map", "package", "range", "return", "select", "struct", + "switch", "type", "var"] + + if (goKeywords.contains(name)) { + return name + "Param" + } + return name + } + + /** + * Build a spec path (excluding the first placeholder). + * Example: ["ip", "availability"] with original path "/l3-networks/{l3NetworkUuid}/ip/{ip}/availability" + * Returns: fmt.Sprintf("ip/%s/availability", ip) + */ + private String buildSpecPath(List remainingPlaceholders) { + if (remainingPlaceholders.isEmpty()) { + return '""' + } + + // Locate the path segment after the first placeholder + int firstPlaceholderEnd = path.indexOf('}') + 1 + if (firstPlaceholderEnd <= 0) { + return '""' + } + + String pathAfterFirst = path.substring(firstPlaceholderEnd) + if (pathAfterFirst.startsWith('/')) { + pathAfterFirst = pathAfterFirst.substring(1) + } + + // Replace remaining {placeholder} segments with %s + String formatStr = pathAfterFirst.replaceAll(/\{[^}]+\}/, '%s') + + if (remainingPlaceholders.isEmpty()) { + return "\"${formatStr}\"" + } + + // Build the fmt.Sprintf call + String params = remainingPlaceholders.collect { toSafeGoParamName(it) }.join(', ') + return "fmt.Sprintf(\"${formatStr}\", ${params})" + } + + /** + * Build the full path (used when no *WithSpec helper exists, such as POST). + * Example: ["eipUuid", "vmNicUuid"] with path "/eips/{eipUuid}/vm-instances/nics/{vmNicUuid}" + * Returns: fmt.Sprintf("v1/eips/%s/vm-instances/nics/%s", eipUuid, vmNicUuid) + */ + private String buildFullPath(List placeholders) { + if (placeholders.isEmpty()) { + return "\"${path}\"" + } + + // Replace all {placeholder} tokens with %s + String formatStr = path.replaceAll(/\{[^}]+\}/, '%s') + + // Add the v1 prefix + if (!formatStr.startsWith("v1/")) { + if (formatStr.startsWith("/")) { + formatStr = "v1" + formatStr + } else { + formatStr = "v1/" + formatStr + } + } + + // Build the fmt.Sprintf call + String params = placeholders.collect { toSafeGoParamName(it) }.join(', ') + return "fmt.Sprintf(\"${formatStr}\", ${params})" + } + + /** + * Remove all placeholders from a URL. + * Example: "/l3-networks/{l3NetworkUuid}/ip/{ip}/availability" -> "/l3-networks" + * Keep the base path for GetWithSpec and similar helpers. + */ + private String removePlaceholders(String apiPath) { + // Find the position of the first '{' + int firstPlaceholder = apiPath.indexOf('{') + if (firstPlaceholder == -1) { + return apiPath + } + + // Return the path before the first placeholder, trimming the trailing slash + String basePath = apiPath.substring(0, firstPlaceholder) + if (basePath.endsWith('/')) { + basePath = basePath.substring(0, basePath.length() - 1) + } + return basePath + } + + /** + * Build a path spec string for GetWithSpec and similar helpers. + * Example: ["l3NetworkUuid", "ip"] -> 'fmt.Sprintf("%s/ip/%s/availability", l3NetworkUuid, ip)' + * + * Steps: + * 1. Extract the path segment after placeholders. + * 2. Replace placeholders with %s. + * 3. Build fmt.Sprintf using safe parameter names. + */ + private String buildPathSpec(List placeholders) { + if (placeholders.isEmpty()) { + return '""' + } + + // Re-parse the original path to build the portion after placeholders + String pathAfterBase = path + int firstPlaceholder = path.indexOf('{') + if (firstPlaceholder != -1) { + pathAfterBase = path.substring(firstPlaceholder) + } + + // Replace {placeholder} with %s + String formatStr = pathAfterBase.replaceAll(/\{[^}]+\}/, '%s') + + // Drop a leading slash if present + if (formatStr.startsWith('/')) { + formatStr = formatStr.substring(1) + } -"""; - file.add(f) - return file + // Build the fmt.Sprintf call with safe parameter names + String params = placeholders.collect { toSafeGoParamName(it) }.join(', ') + return "fmt.Sprintf(\"${formatStr}\", ${params})" } } diff --git a/rest/src/main/resources/scripts/GoInventory.groovy b/rest/src/main/resources/scripts/GoInventory.groovy index d3e1c18b500..38bdd29a62b 100644 --- a/rest/src/main/resources/scripts/GoInventory.groovy +++ b/rest/src/main/resources/scripts/GoInventory.groovy @@ -1,11 +1,12 @@ package scripts -import com.fasterxml.jackson.core.type.TypeReference import org.zstack.core.Platform +import org.zstack.header.longjob.LongJobFor import org.zstack.header.message.APIParam import org.zstack.header.message.OverriddenApiParam import org.zstack.header.message.OverriddenApiParams import org.zstack.header.rest.APINoSee +import org.zstack.header.rest.RestRequest import org.zstack.header.rest.RestResponse import org.zstack.header.search.Inventory import org.zstack.rest.sdk.SdkFile @@ -15,85 +16,1059 @@ import org.zstack.utils.Utils import org.zstack.utils.logging.CLogger import java.lang.reflect.Field +import java.lang.reflect.Modifier import java.lang.reflect.ParameterizedType import java.lang.reflect.Type import java.lang.reflect.TypeVariable import java.util.stream.Collectors +/** + * Go SDK Inventory/View generator + */ class GoInventory implements SdkTemplate { private static final CLogger logger = Utils.getLogger(GoInventory.class) - private Set inventories = new ArrayList<>() - private Set markedInventories = new ArrayList<>() + private Set inventories = new HashSet<>() + private Set markedInventories = new HashSet<>() + private Map viewStructNameMap = new HashMap<>() + // Track additional classes that need View generation (referenced but not @Inventory annotated) + private Set additionalClasses = new HashSet<>() + // Track all generated view struct names to avoid duplicates + private Set generatedViewStructs = new HashSet<>() + // Track generated view files to avoid duplicates + private Set generatedViewFiles = new HashSet<>() + + // Track LongJob mappings: API class -> LongJob class + private static Map longJobMappings = new HashMap<>() + + // Track param nested types (types used in API request params) + private Set paramNestedTypes = new HashSet<>() + // Track generated param struct names to avoid duplicates + private Set generatedParamStructs = new HashSet<>() + + // Track generated client method names to avoid duplicates across all action files + private Set generatedClientMethods = new HashSet<>() + + // Flag to indicate we're generating for param (not view) + private boolean generatingForParam = false + + // Track current class being generated (for detecting self-references/recursive types) + private Class currentGeneratingClass = null + + // Centralized list of all pre-parsed API templates + private List allApiTemplates = new ArrayList<>() + + /** + * Get all registered inventory classes + */ + Set getInventories() { + if (inventories.isEmpty()) { + inventories.addAll(Platform.reflections.getTypesAnnotatedWith(Inventory.class)) + } + return inventories + } + + /** + * Scan all @LongJobFor annotations and build API -> LongJob mappings + */ + private void scanLongJobMappings() { + logger.warn("[GoSDK] Scanning @LongJobFor annotations...") + + try { + // Fetch all classes annotated with @LongJobFor + Set> longJobClasses = Platform.reflections.getTypesAnnotatedWith(LongJobFor.class) + + longJobClasses.each { Class longJobClass -> + try { + // Read the target API class from the annotation + LongJobFor annotation = longJobClass.getAnnotation(LongJobFor.class) + Class targetApiClass = annotation.value() + + // Store the mapping + longJobMappings.put(targetApiClass, longJobClass) + + logger.debug("[GoSDK] Found LongJob: ${longJobClass.simpleName} for API: ${targetApiClass.simpleName}") + } catch (Exception e) { + logger.warn("[GoSDK] Failed to process LongJob class ${longJobClass.name}: ${e.message}") + } + } + + logger.warn("[GoSDK] Total LongJob mappings: ${longJobMappings.size()}") + } catch (Exception e) { + logger.error("[GoSDK] Failed to scan LongJob mappings: ${e.message}") + } + } + + /** + * Get LongJob mappings (for GoApiTemplate) + */ + static Map getLongJobMappings() { + return longJobMappings + } + + /** + * Pre-analyze all API classes once and cache metadata. + * This avoids expensive re-instantiation of GoApiTemplate and redundant logging. + */ + private void prepareApiTemplates() { + logger.warn("[GoSDK] Pre-parsing all API classes...") + GoApiTemplate.setKnownInventoryClasses(getInventories()) + GoApiTemplate.setLongJobMappings(getLongJobMappings()) + Set> allApiClasses = Platform.reflections.getTypesAnnotatedWith(RestRequest.class) + + allApiClasses.each { Class apiClass -> + if (apiClass.isInterface() || Modifier.isAbstract(apiClass.getModifiers())) { + return + } + + try { + GoApiTemplate template = new GoApiTemplate(apiClass, this) + // If it's a valid template (has @RestRequest) + if (template.at != null) { + allApiTemplates.add(template) + } + } catch (Throwable e) { + logger.warn("[GoSDK] Error pre-parsing API class ${apiClass.name}: ${e.class.name}: ${e.message}", e) + } + } + logger.warn("[GoSDK] Pre-parsing complete. Cached ${allApiTemplates.size()} API templates.") + } + + /** + * Validate generated views against referenced response views + */ + private void validateGeneratedViews() { + int missingCount = 0 + allApiTemplates.each { GoApiTemplate template -> + Class responseClass = template.getResponseClass() + if (responseClass != null) { + String viewName = getViewStructName(responseClass) + if (!generatedViewStructs.contains(viewName)) { + logger.warn("[GoSDK] Reference to missing view: ${viewName} (referenced by ${template.getApiMsgClazz().simpleName})") + missingCount++ + } + } + } + if (missingCount > 0) { + logger.warn("[GoSDK] Total missing response views: ${missingCount}. These APIs might fail to compile in Go.") + } else { + logger.warn("[GoSDK] View validation passed. All referenced response views are generated.") + } + } @Override List generate() { - def file = [] + def files = [] + + logger.warn("[GoSDK] ===== GoInventory.generate() START =====") + logger.warn("[GoSDK] GoInventory.generate() starting...") + + // 0. Scan LongJob mappings + scanLongJobMappings() + logger.warn("[GoSDK] Scanned ${longJobMappings.size()} LongJob mappings") + + // Ensure inventories are loaded early + getInventories() + logger.warn("[GoSDK] Loaded " + inventories.size() + " inventories") + + // 1. Pre-parse all APIs once to avoid O(N*M) processing and redundant logging + prepareApiTemplates() + logger.warn("[GoSDK] prepareApiTemplates complete. Found " + allApiTemplates.size() + " valid API templates") + + // 1. Generate view files (Resource Grouping) + logger.warn("[GoSDK] Starting generateViewFiles()...") + files.addAll(generateViewFiles()) + logger.warn("[GoSDK] Completed generateViewFiles(). Generated " + generatedViewStructs.size() + " view structs") + + // 1b. Generate action and param files (Resource Grouping) + files.addAll(generateActionFiles()) + files.addAll(generateParamFiles()) + + // 2. Generate catch-all other views (for APIs without a matching @Inventory class) + logger.warn("[GoSDK] Before other_views.go, generatedViewStructs size: " + generatedViewStructs.size()) + files.add(generateOtherViewsFile()) + logger.warn("[GoSDK] After other_views.go, generatedViewStructs size: " + generatedViewStructs.size()) + + + // 4. Generate other params file + files.add(generateOtherParamsFile()) + + // 5. Generate other actions file + files.add(generateOtherActionsFile()) + + // 6. Generate view files for additional referenced classes (iterative discovery) + files.addAll(generateAdditionalViewFiles()) + + // 7. Base files + files.add(generateBaseViewFile()) + + // Load session additional views from template (special case for WebUISessionView) + files.add(loadSessionViewsTemplate()) + + files.add(loadBaseParamTemplate()) + + // Generate param nested types file from template + files.add(loadBaseParamTypesTemplate()) + + // Generate login params from template (special case) + files.add(loadLoginParamsTemplate()) + + // Note: client.go is manually maintained, not auto-generated + + // 8. Generate test files (unit tests + integration tests) + def testTemplate = new GoTestTemplate(this, allApiTemplates, inventories) + def testFiles = testTemplate.generate() + files.addAll(testFiles) + logger.warn("[GoSDK] Generated ${testFiles.size()} test files") + + // 9. Validate that all referenced response views were generated + validateGeneratedViews() + + logger.warn("[GoSDK] GoInventory.generate() complete. Total files: " + files.size()) + logger.warn("[GoSDK] ===== GoInventory.generate() END =====") + return files + } + + /** + * Generate view files for additional classes (referenced but not @Inventory annotated) + * These are classes like SnapshotLeafInventory, ServiceStatus, etc. + * Uses iterative approach to handle newly discovered types during generation. + */ + private List generateAdditionalViewFiles() { + def files = [] + + if (additionalClasses.isEmpty()) { + return files + } + + logger.warn("[GoSDK] Generating additional view classes, initial count: " + additionalClasses.size()) + + // Use a list to iterate and allow adding new classes during iteration + def classesToProcess = new ArrayList(additionalClasses) + def discoveredClasses = new HashSet() // Track discovered classes (not generated yet) + int index = 0 + + // First pass: discover all classes and their dependencies + // Don't add to generatedViewStructs here - just collect classes + while (index < classesToProcess.size()) { + Class clz = classesToProcess.get(index) + String structName = getViewStructName(clz) + + // Skip if already in @Inventory or already discovered + if (!discoveredClasses.contains(clz) && !generatedViewStructs.contains(structName)) { + discoveredClasses.add(clz) + + // Call generateViewStruct to trigger dependency discovery + // (it adds new types to additionalClasses) + generateViewStruct(clz, structName) + + // Check if new classes were added during struct generation + additionalClasses.each { Class newClz -> + String newStructName = getViewStructName(newClz) + if (!classesToProcess.contains(newClz) && + !discoveredClasses.contains(newClz) && + !generatedViewStructs.contains(newStructName)) { + classesToProcess.add(newClz) + } + } + } + index++ + } + + logger.warn("[GoSDK] Total additional classes after discovery: " + discoveredClasses.size()) + + // Group discovered classes by simple name prefix for file organization + def grouped = new HashMap>() + discoveredClasses.each { Class clz -> + String prefix = clz.simpleName.replaceAll('Inventory$', '').replaceAll('VO$', '') + if (!grouped.containsKey(prefix)) { + grouped.put(prefix, new HashSet()) + } + grouped.get(prefix).add(clz) + } + + grouped.each { String prefix, Set classes -> + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/view/" + sdkFile.fileName = "${toSnakeCase(prefix)}_additional_views.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package view\n\n") + content.append("import \"time\"\n\n") + content.append("var _ = time.Now() // avoid unused import\n\n") + + classes.each { Class clz -> + String structName = getViewStructName(clz) + if (!generatedViewStructs.contains(structName)) { + generatedViewStructs.add(structName) + content.append(generateViewStruct(clz, structName)) + logger.debug("[GoSDK] Generated additional view: " + structName) + } + } + + sdkFile.content = content.toString() + files.add(sdkFile) + } + + return files + } + /** + * Generate base view file + */ + private SdkFile generateBaseViewFile() { def sdkFile = new SdkFile() - sdkFile.subPath = "/api/" - sdkFile.fileName = "Inventory.go" - sdkFile.content = "package api\n\n" - inventories.addAll(Platform.reflections.getTypesAnnotatedWith(Inventory.class)) - while (inventories.size() != 0) { - def inventoriesCopy = new TreeSet(Comparator.comparing({it.simpleName})) - sdkFile.content += inventoriesCopy.stream().filter({ it -> it.simpleName.contains("Inventory") }).map({ it -> generateStruct(it, it.simpleName) }).collect(Collectors.toList()).join("") - markedInventories.addAll(inventoriesCopy) - inventories.removeAll(markedInventories) - } - file.add(sdkFile) - return file - } - - String generateStruct(Class inventoryClazz, String structName) { - if (inventoryClazz == null) { + sdkFile.subPath = "/pkg/view/" + sdkFile.fileName = "base_views.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package view\n\n") + content.append("import \"time\"\n\n") + content.append("// BaseInfoView holds common identity fields\n") + content.append("type BaseInfoView struct {\n") + content.append("\tUUID string `json:\"uuid\"` // Unique resource identifier\n") + content.append("\tName string `json:\"name,omitempty\"` // Resource name\n") + content.append("}\n\n") + + content.append("type BaseTimeView struct {\n") + content.append("\tCreateDate time.Time `json:\"createDate,omitempty\"` // Creation time\n") + content.append("\tLastOpDate time.Time `json:\"lastOpDate,omitempty\"` // Last operation time\n") + content.append("}\n\n") + + // Add generic wrapper types for simple return values + content.append("// Generic wrapper types for APIs that return simple data types\n\n") + content.append("// MapView wraps map return values\n") + content.append("type MapView map[string]interface{}\n\n") + content.append("// ListView wraps list/array return values\n") + content.append("type ListView []interface{}\n\n") + content.append("// StringView wraps string return values\n") + content.append("type StringView string\n\n") + content.append("// BooleanView wraps boolean return values\n") + content.append("type BooleanView bool\n\n") + content.append("// IntView wraps integer return values\n") + content.append("type IntView int\n\n") + content.append("// LongView wraps long integer return values\n") + content.append("type LongView int64\n\n") + content.append("// SuccessView represents successful operation with no data return\n") + content.append("type SuccessView struct {\n") + content.append("\tSuccess bool `json:\"success\"`\n") + content.append("}\n") + + sdkFile.content = content.toString() + return sdkFile + } + + /** + * Load base_params.go from template file + */ + private SdkFile loadBaseParamTemplate() { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/param/" + sdkFile.fileName = "base_params.go" + + try { + def templateStream = this.class.getResourceAsStream("/scripts/templates/base_params.go.template") + if (templateStream != null) { + sdkFile.content = templateStream.text + logger.warn("[GoSDK] Loaded base_params.go from template file") + } else { + logger.error("[GoSDK] base_params.go.template not found in classpath") + throw new RuntimeException("base_params.go.template not found") + } + } catch (Exception e) { + logger.error("[GoSDK] Failed to load base_params.go template: ${e.message}") + throw e + } + + return sdkFile + } + + /** + * Generate errors file + */ + private SdkFile generateErrorsFile() { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/errors/" + sdkFile.fileName = "errors.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package errors\n\n") + content.append("import \"fmt\"\n\n") + content.append("// Error custom error type\n") + content.append("type Error string\n\n") + content.append("func (e Error) Error() string {\n") + content.append("\treturn string(e)\n") + content.append("}\n\n") + content.append("const (\n") + content.append("\tErrNotFound = Error(\"NotFoundError\")\n") + content.append("\tErrDuplicateId = Error(\"DuplicateIdError\")\n") + content.append("\tErrParameter = Error(\"ParameterError\")\n") + content.append("\tErrAuth = Error(\"AuthError\")\n") + content.append("\tErrPermission = Error(\"PermissionError\")\n") + content.append("\tErrInternal = Error(\"InternalError\")\n") + content.append(")\n\n") + content.append("// Wrap wraps an error with a message\n") + content.append("func Wrap(err error, message string) error {\n") + content.append("\tif err == nil {\n") + content.append("\t\treturn nil\n") + content.append("\t}\n") + content.append("\treturn fmt.Errorf(\"%s: %w\", message, err)\n") + content.append("}\n\n") + content.append("// ErrorCode API error code structure\n") + content.append("type ErrorCode struct {\n") + content.append("\tCode string `json:\"code\"`\n") + content.append("\tDescription string `json:\"description\"`\n") + content.append("\tDetails string `json:\"details\"`\n") + content.append("\tElaboration string `json:\"elaboration\"`\n") + content.append("\tCause *ErrorCode `json:\"cause\"`\n") + content.append("}\n") + + sdkFile.content = content.toString() + return sdkFile + } + + /** + * Generate view files + */ + + /** + * Group inventories by resource + */ + + /** + * Get view struct name + */ + String getViewStructName(Class clz) { + if (viewStructNameMap.containsKey(clz)) { + return viewStructNameMap.get(clz) + } + + String name = clz.simpleName + String structName + + if (name.endsWith("Inventory")) { + structName = name.replace("Inventory", "InventoryView") + } else if (name.endsWith("Reply")) { + structName = name.replace("Reply", "View").replaceAll('^API', '') + } else { + structName = name + "View" + } + + if (structName.startsWith("API")) { + structName = structName.substring(3) + } + + viewStructNameMap.put(clz, structName) + return structName + } + + /** + * Convert to snake_case + */ + private String toSnakeCase(String name) { + return name.replaceAll('([a-z])([A-Z])', '$1_$2').toLowerCase() + } + + /** + * Find a field in the class hierarchy (including parent classes) + */ + private Field findFieldInHierarchy(Class clazz, String fieldName) { + if (clazz == null || fieldName == null) { + return null + } + + // Search through all fields including inherited ones + for (Field f : FieldUtils.getAllFields(clazz)) { + if (f.name == fieldName) { + return f + } + } + + // Manual search through hierarchy as fallback + Class current = clazz + while (current != null && current != Object.class) { + try { + return current.getDeclaredField(fieldName) + } catch (NoSuchFieldException e) { + current = current.superclass + } + } + + return null + } + + /** + * Generate view struct + */ + String generateViewStruct(Class inventoryClazz, String structName) { + if (inventoryClazz == null || structName == null) { return "" } + logger.warn("[GoSDK] generateViewStruct called: inventoryClass=${inventoryClazz.simpleName}, structName=${structName}") + + // Track current class for detecting self-references + Class previousClass = currentGeneratingClass + currentGeneratingClass = inventoryClazz + def apiParamMap = new HashMap() if (inventoryClazz.isAnnotationPresent(OverriddenApiParams.class)) { - for (OverriddenApiParam overriddenApiParam :inventoryClazz.getAnnotation(OverriddenApiParams.class).value()) { - apiParamMap.put(overriddenApiParam.field(), overriddenApiParam.param()) + for (OverriddenApiParam oap : inventoryClazz.getAnnotation(OverriddenApiParams.class).value()) { + apiParamMap.put(oap.field(), oap.param()) + } + } + + def fieldMap = new LinkedHashMap() + FieldUtils.getAllFields(inventoryClazz).each { Field f -> + if (!Modifier.isStatic(f.modifiers)) { + fieldMap.put(f.name, f) } } - def fieldMap = new HashMap() - FieldUtils.getAllFields(inventoryClazz).forEach({ it -> fieldMap.put(it.name, it) }) + if (inventoryClazz.isAnnotationPresent(RestResponse.class)) { - //allTo def at = inventoryClazz.getAnnotation(RestResponse.class) if (at.allTo() != "") { - fieldMap = new HashMap() - fieldMap.put(at.allTo(), inventoryClazz.getDeclaredField(at.allTo())) + fieldMap = new LinkedHashMap() + Field targetField = findFieldInHierarchy(inventoryClazz, at.allTo()) + if (targetField != null) { + fieldMap.put(at.allTo(), targetField) + } else { + logger.warn("[GoSDK] Field '" + at.allTo() + "' not found in class " + inventoryClazz.simpleName + " or its parents") + } } else if (at.fieldsTo().size() != 0 && at.fieldsTo()[0] != "all") { - fieldMap = new HashMap() + fieldMap = new LinkedHashMap() for (String fieldsTo : at.fieldsTo()) { - def spilt = fieldsTo.split("=") - if (spilt.size() == 1) { - fieldMap.put(spilt[0], inventoryClazz.getDeclaredField(spilt[0])) + def split = fieldsTo.split("=") + String fieldName = split.size() == 1 ? split[0] : split[1] + String outputName = split[0] + Field targetField = findFieldInHierarchy(inventoryClazz, fieldName) + if (targetField != null) { + fieldMap.put(outputName, targetField) } else { - fieldMap.put(spilt[0], inventoryClazz.getDeclaredField(spilt[1])) + logger.warn("[GoSDK] Field '" + fieldName + "' not found in class " + inventoryClazz.simpleName + " or its parents") } } } } - def inventoryBuilder = new StringBuilder() - inventoryBuilder.append("type ${structName} struct {\n") - fieldMap.forEach { k, v -> - if (v.isAnnotationPresent(APINoSee.class)) { + + def builder = new StringBuilder() + builder.append("// ${structName} ${getStructDescription(inventoryClazz)}\n") + builder.append("type ${structName} struct {\n") + + // InventoryView embeds BaseInfoView and BaseTimeView + if (structName.endsWith('InventoryView')) { + builder.append("\tBaseInfoView\n") + builder.append("\tBaseTimeView\n") + } + + boolean hasFields = false + fieldMap.each { String fieldName, Field field -> + if (field.isAnnotationPresent(APINoSee.class)) { return } - if ('error' == k && structName.endsWith('Rsp')) { + if ('error' == fieldName && structName.endsWith('View')) { return } - //name - String name = k.substring(0, 1).toUpperCase() + k.substring(1) - inventoryBuilder.append("\t" + name) - //type - inventoryBuilder.append(" " + generateFieldGeneric(v)) - //json tag - inventoryBuilder.append(' `json:"' + k + '" ' + generateValidatorString(v, apiParamMap) + '`\n') + + // Skip fields already covered by BaseInfoView and BaseTimeView + if (structName.endsWith('InventoryView')) { + if (fieldName in ['uuid', 'name', 'createDate', 'lastOpDate']) { + return + } + } + + String goFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1) + String baseFieldType = generateFieldGeneric(field) + + // View structs avoid pointer fields; omitempty handles optional zero values + String fieldType = baseFieldType + String jsonTag = generateJsonTag(fieldName, field, apiParamMap) + + builder.append("\t${goFieldName} ${fieldType} ${jsonTag}\n") + hasFields = true + } + + // For empty Event/Reply classes (e.g., success-only responses), add a Success field + if (!hasFields && structName.endsWith('View') && !structName.endsWith('InventoryView')) { + builder.append("\t// Empty response - operation succeeded\n") + } + + builder.append("}\n\n") + + // Restore previous class context + currentGeneratingClass = previousClass + + String result = builder.toString() + logger.warn("[GoSDK] generateViewStruct result for ${structName}: length=${result.length()}") + return result + } + + /** + * Generate param struct (called by GoApiTemplate) + * Nested types are collected but generated separately in base_param_types.go + */ + String generateParamStruct(Class apiMsgClazz, String paramStructName, String detailParamStructName, RestRequest restRequest) { + if (apiMsgClazz == null) { + return "" + } + + // Enable param mode - will use different type naming and collect nested types + generatingForParam = true + + def apiParamMap = new HashMap() + if (apiMsgClazz.isAnnotationPresent(OverriddenApiParams.class)) { + for (OverriddenApiParam oap : apiMsgClazz.getAnnotation(OverriddenApiParams.class).value()) { + apiParamMap.put(oap.field(), oap.param()) + } + } + + def fieldMap = new LinkedHashMap() + FieldUtils.getAllFields(apiMsgClazz).each { Field f -> + if (!Modifier.isStatic(f.modifiers) && !f.isAnnotationPresent(APINoSee.class)) { + fieldMap.put(f.name, f) + } + } + + def builder = new StringBuilder() + + // Generate detail param struct + builder.append("// ${detailParamStructName} ${getStructDescription(apiMsgClazz)} detail param\n") + builder.append("type ${detailParamStructName} struct {\n") + + // Extract path parameters from @RestRequest to skip them + // Path parameters come from URL, not request body + def pathParams = [] + if (restRequest != null && restRequest.path() != null) { + def matcher = (restRequest.path() =~ /\{([^}]+)\}/) + while (matcher.find()) { + pathParams.add(matcher.group(1)) + } + } + + // Skip fields that should be in BaseParam or QueryParam + // Also skip path parameters (they come from URL, not request body) + def skipFields = ['systemTags', 'userTags', 'requestIp', 'session', 'timeout', 'id', 'serviceId', 'creatingAccountUuid'] + pathParams + + fieldMap.each { String fieldName, Field field -> + if (field.isAnnotationPresent(APINoSee.class)) { + return + } + if (skipFields.contains(fieldName)) { + return + } + + String goFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1) + boolean isOptional = isOptionalField(field, apiParamMap) + String fieldType = generateParamFieldGeneric(field, apiParamMap, isOptional) + String jsonTag = generateJsonTag(fieldName, field, apiParamMap) + + builder.append("\t${goFieldName} ${fieldType} ${jsonTag}\n") + } + + builder.append("}\n\n") + + // Generate wrapper param struct + // Prefer @RestRequest.parameterName; if missing or "null", derive it from the class name + String jsonKey + if (restRequest != null && restRequest.parameterName() != null && + !restRequest.parameterName().isEmpty() && !restRequest.parameterName().equals("null")) { + jsonKey = restRequest.parameterName() + } else { + // Derive action name from API class, e.g., APIUpdateImageMsg -> updateImage + String apiClassName = apiMsgClazz.simpleName + String actionName = apiClassName.replaceAll('^API', '').replaceAll('Msg$', '') + jsonKey = actionName.substring(0, 1).toLowerCase() + actionName.substring(1) + } + // Use a consistent field name Params for convenience + String fieldName = "Params" + + builder.append("// ${paramStructName} ${getStructDescription(apiMsgClazz)} request param\n") + builder.append("type ${paramStructName} struct {\n") + builder.append("\tBaseParam\n") + builder.append("\t${fieldName} ${detailParamStructName} `json:\"${jsonKey}\"`\n") + builder.append("}\n") + + generatingForParam = false + + return builder.toString() + } + + /** + * Generate field type for param (uses different naming - no View suffix) + */ + private String generateParamFieldGeneric(Field field, Map apiParamMap, boolean isOptional) { + if (!(field.getGenericType() instanceof ParameterizedType)) { + return generateParamFieldType(field, null, apiParamMap, isOptional) + } + String typeName = "" + if (Collection.class.isAssignableFrom(field.type)) { + Type type = ((ParameterizedType) field.getGenericType()).actualTypeArguments[0] + // Slices and maps stay non-pointer because they are reference types + typeName = "[]" + generateParamFieldType(null, type, apiParamMap, false) + } + if (Map.class.isAssignableFrom(field.type)) { + Type value = ((ParameterizedType) field.getGenericType()).actualTypeArguments[1] + // Slices and maps stay non-pointer because they are reference types + typeName = "map[string]" + generateParamFieldType(null, value, apiParamMap, false) + } + return typeName + } + + /** + * Generate field type for param - complex types go to param package + */ + private String generateParamFieldType(Field field, Type type, Map apiParamMap, boolean isOptional) { + String baseType = null + + if (field != null) { + Class fieldType = field.type + String goType = mapJavaTypeToGoType(fieldType) + if (goType != null) { + baseType = goType + } else if (isGeneratableClass(fieldType)) { + // For complex types in param, add to paramNestedTypes for generation in param package + paramNestedTypes.add(fieldType) + logger.debug("[GoSDK] Added param nested type: " + fieldType.simpleName) + baseType = getParamStructName(fieldType) + } else if (!fieldType.isPrimitive() && !fieldType.isEnum()) { + baseType = "interface{}" + } + } + + if (type != null && baseType == null) { + if (type instanceof Class) { + Class clz = (Class) type + String goType = mapJavaTypeToGoType(clz) + if (goType != null) { + baseType = goType + } else if (isGeneratableClass(clz)) { + // For complex types in param, add to paramNestedTypes + paramNestedTypes.add(clz) + logger.debug("[GoSDK] Added param nested type: " + clz.simpleName) + baseType = getParamStructName(clz) + } else { + baseType = "interface{}" + } + } else if (type instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType) type + Class clz = (Class) pt.rawType + if (isGeneratableClass(clz)) { + paramNestedTypes.add(clz) + baseType = getParamStructName(clz) + } else { + baseType = "interface{}" + } + } else { + baseType = "interface{}" + } + } + + if (baseType == null) { + baseType = "interface{}" + } + + // Optional basic types (string, int, int64, bool, etc.) use pointers; interface{}, slices, and maps stay as non-pointers because they already carry reference/nil semantics + def basicTypes = ["string", "int", "int64", "int32", "float64", "float32", "bool"] as Set + if (isOptional && !baseType.startsWith("[") && !baseType.startsWith("map[") && + !baseType.equals("interface{}") && basicTypes.contains(baseType)) { + return "*" + baseType } - inventoryBuilder.append("}\n\n") - return inventoryBuilder.toString() + + return baseType + } + + /** + * Get param struct name (without View suffix) + */ + private String getParamStructName(Class clz) { + String name = clz.simpleName + if (clz.enclosingClass != null) { + String outerName = clz.enclosingClass.simpleName.replaceAll('^API', '').replaceAll('Msg$', '').replaceAll('Action$', '') + // For nested inner classes, include parent for uniqueness + name = outerName + "_" + name + logger.debug("[GoSDK] Identified inner class: ${clz.name} -> ${name}Param") + } + if (name.endsWith("Inventory")) { + return name.replace("Inventory", "") + "Param" + } + return name + "Param" + } + + /** + * Generate param nested types content + */ + private String generateParamNestedTypes() { + if (paramNestedTypes.isEmpty()) { + return "" + } + + def builder = new StringBuilder() + + // Use a queue-like approach to handle newly discovered types during generation + def typesToProcess = new ArrayList(paramNestedTypes) + int index = 0 + + // Enable param mode for nested type generation + generatingForParam = true + + while (index < typesToProcess.size()) { + Class clz = typesToProcess.get(index) + String structName = getParamStructName(clz) + if (!generatedParamStructs.contains(structName)) { + generatedParamStructs.add(structName) + builder.append(generateParamNestedStruct(clz, structName)) + + // Check if new types were added during generation + paramNestedTypes.each { Class newClz -> + if (!typesToProcess.contains(newClz)) { + typesToProcess.add(newClz) + } + } + } + index++ + } + + generatingForParam = false + + return builder.toString() + } + + /** + * Load base_param_types.go from template file and append dynamically generated param types + */ + private SdkFile loadBaseParamTypesTemplate() { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/param/" + sdkFile.fileName = "base_param_types.go" + + def content = new StringBuilder() + + try { + // Load template with fixed base param types + def templateStream = this.class.getResourceAsStream("/scripts/templates/base_param_types.go.template") + if (templateStream != null) { + content.append(templateStream.text) + logger.warn("[GoSDK] Loaded base_param_types.go from template file") + } else { + logger.error("[GoSDK] base_param_types.go.template not found in classpath") + throw new RuntimeException("base_param_types.go.template not found") + } + + // Append dynamically generated param nested types + if (!paramNestedTypes.isEmpty()) { + logger.warn("[GoSDK] Appending ${paramNestedTypes.size()} dynamically generated param types") + content.append("\n// ========== Dynamically Generated Param Types ==========\n\n") + String nestedTypes = generateParamNestedTypes() + content.append(nestedTypes) + } else { + logger.warn("[GoSDK] No dynamic param nested types to append") + } + + sdkFile.content = content.toString() + } catch (Exception e) { + logger.error("[GoSDK] Failed to load base_param_types.go template: ${e.message}") + throw e + } + + return sdkFile + } + + /** + * Load login_params.go from template file + */ + private SdkFile loadLoginParamsTemplate() { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/param/" + sdkFile.fileName = "login_params.go" + + try { + def templateStream = this.class.getResourceAsStream("/scripts/templates/login_params.go.template") + if (templateStream != null) { + sdkFile.content = templateStream.text + logger.warn("[GoSDK] Loaded login_params.go from template file") + + // Mark Login-related params as generated to avoid duplicates + generatedParamStructs.add("LoginByAccountParam") + generatedParamStructs.add("LoginByAccountDetailParam") + generatedParamStructs.add("LogInByUserParam") + generatedParamStructs.add("LogInByUserDetailParam") + generatedParamStructs.add("LoginIAM2VirtualIDWithLdapParam") + generatedParamStructs.add("LoginIAM2VirtualIDWithLdapDetailParam") + generatedParamStructs.add("LoginIAM2PlatformParam") + generatedParamStructs.add("LoginIAM2PlatformDetailParam") + generatedParamStructs.add("ValidateSessionParam") + } else { + logger.error("[GoSDK] login_params.go.template not found in classpath") + throw new RuntimeException("login_params.go.template not found") + } + } catch (Exception e) { + logger.error("[GoSDK] Failed to load login_params.go template: ${e.message}") + throw e + } + + return sdkFile + } + + /** + * Load session_additional_views.go from template file + */ + private SdkFile loadSessionViewsTemplate() { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/view/" + sdkFile.fileName = "session_additional_views.go" + + try { + def templateStream = this.class.getResourceAsStream("/scripts/templates/session_additional_views.go.template") + if (templateStream != null) { + sdkFile.content = templateStream.text + logger.warn("[GoSDK] Loaded session_additional_views.go from template file") + + // Mark Session-related views as generated to avoid duplicates + generatedViewStructs.add("SessionInventoryView") + generatedViewStructs.add("WebUISessionView") + } else { + logger.error("[GoSDK] session_additional_views.go.template not found in classpath") + throw new RuntimeException("session_additional_views.go.template not found") + } + } catch (Exception e) { + logger.error("[GoSDK] Failed to load session_additional_views.go template: ${e.message}") + throw e + } + + return sdkFile + } + + /** + * Generate a nested struct for param package + */ + private String generateParamNestedStruct(Class clazz, String structName) { + if (clazz == null) { + return "" + } + + // Build APIParam map for this class + def apiParamMap = new HashMap() + if (clazz.isAnnotationPresent(OverriddenApiParams.class)) { + for (OverriddenApiParam oap : clazz.getAnnotation(OverriddenApiParams.class).value()) { + apiParamMap.put(oap.field(), oap.param()) + } + } + + def fieldMap = new LinkedHashMap() + FieldUtils.getAllFields(clazz).each { Field f -> + if (!Modifier.isStatic(f.modifiers)) { + fieldMap.put(f.name, f) + } + } + + def builder = new StringBuilder() + builder.append("// ${structName} ${clazz.simpleName} param struct\n") + builder.append("type ${structName} struct {\n") + + fieldMap.each { String fieldName, Field field -> + if (field.isAnnotationPresent(APINoSee.class)) { + return + } + + String goFieldName = fieldName.substring(0, 1).toUpperCase() + fieldName.substring(1) + // Nested types are typically optional (used in request params) + boolean isOptional = isOptionalField(field, apiParamMap) + String fieldType = generateParamFieldGeneric(field, apiParamMap, isOptional) + String jsonTag = "`json:\"${fieldName},omitempty\"`" + + builder.append("\t${goFieldName} ${fieldType} ${jsonTag}\n") + } + + builder.append("}\n\n") + return builder.toString() + } + + /** + * Check if there are param nested types to generate + */ + boolean hasParamNestedTypes() { + return !paramNestedTypes.isEmpty() + } + + /** + * For backward compatibility + */ + String generateStruct(Class clazz, String structName) { + return generateViewStruct(clazz, structName) + } + + private String getStructDescription(Class clz) { + String name = clz.simpleName + return name.replaceAll("API", "") + .replaceAll('Msg$', "") + .replaceAll('Inventory$', "") + .replaceAll('Reply$', "") + } + + private String generateJsonTag(String fieldName, Field field, Map apiParamMap) { + def tags = new StringBuilder() + tags.append('`json:"') + tags.append(fieldName) + + boolean required = false + if (field.isAnnotationPresent(APIParam.class)) { + APIParam param = apiParamMap.containsKey(fieldName) ? + apiParamMap.get(fieldName) : field.getAnnotation(APIParam.class) + required = param.required() + } + + if (!required) { + tags.append(',omitempty') + } + + tags.append('"') + + if (required) { + tags.append(' validate:"required"') + } + + tags.append('`') + return tags.toString() + } + + /** + * Determine whether a field is optional (and should use a pointer type) + */ + private boolean isOptionalField(Field field, Map apiParamMap) { + if (field == null) return false + + // 0. uuid and name always have values; keep them non-pointer + if (field.name in ["uuid", "name"]) { + return false + } + + // 1. Check APIParam.required flag + if (field.isAnnotationPresent(APIParam.class)) { + APIParam param = apiParamMap.containsKey(field.name) ? + apiParamMap.get(field.name) : field.getAnnotation(APIParam.class) + if (!param.required()) { + return true + } + } + + // 2. Certain fields are optional by default + if (field.name in ["description", "lastOpDate", "expiredDate"]) { + return true + } + + // 3. Non-primitive and non-String types default to optional unless required + Class fieldType = field.type + if (fieldType in [String.class, Integer.class, Long.class, Short.class, Byte.class, + Float.class, Double.class, Boolean.class, Date.class, java.sql.Timestamp.class]) { + // If no APIParam annotation is present, treat as optional + if (!field.isAnnotationPresent(APIParam.class)) { + return true + } + } + + return false } private String generateFieldGeneric(Field field) { @@ -113,46 +1088,646 @@ class GoInventory implements SdkTemplate { } private String generateFieldType(Field field, Type type) { - def value = "interface{}" - if (field != null && field.type.name.contains("Inventory")) { - value = field.type.simpleName - inventories.add(field.type) - } - if (type != null && type.typeName.contains("Inventory") && !type.typeName.contains("<")) { - def split = type.typeName.split('\\.') - split = split[split.length - 1].split('\\$') - value = split[split.length - 1] - Class clz; + if (field != null) { + Class fieldType = field.type + // Special-case top-level inventory fields that are maps or collections to keep Go types aligned with client expectations + if ("inventory" == field.name || "inventories" == field.name) { + if (Map.class.isAssignableFrom(fieldType)) { + // Prefer MapView wrapper for arbitrary maps + return "MapView" + } + if (Collection.class.isAssignableFrom(fieldType)) { + // Try to resolve element type; otherwise fall back to ListView + if (field.getGenericType() instanceof ParameterizedType) { + ParameterizedType pt = (ParameterizedType) field.getGenericType() + Type[] args = pt.getActualTypeArguments() + if (args != null && args.length > 0 && args[0] instanceof Class) { + Class elemClz = (Class) args[0] + String goType = mapJavaTypeToGoType(elemClz, true) + if (goType != null) { + return "[]" + goType + } + if (elemClz.isAnnotationPresent(Inventory.class)) { + inventories.add(elemClz) + return "[]" + getViewStructName(elemClz) + } + if (isGeneratableClass(elemClz)) { + additionalClasses.add(elemClz) + return "[]" + getViewStructName(elemClz) + } + } + } + return "ListView" + } + } + + String goType = mapJavaTypeToGoType(fieldType, true) + if (goType != null) { + return goType + } + + // Check for self-reference (recursive type) - use pointer to break cycle + boolean isSelfReference = (currentGeneratingClass != null && fieldType == currentGeneratingClass) + String pointerPrefix = isSelfReference ? "*" : "" + + if (isSelfReference) { + logger.debug("[GoSDK] Detected self-reference in " + currentGeneratingClass.simpleName + " -> " + fieldType.simpleName) + } + + // Check if the class has @Inventory annotation + if (fieldType.isAnnotationPresent(Inventory.class)) { + inventories.add(fieldType) + return pointerPrefix + getViewStructName(fieldType) + } + + // For non-Inventory complex classes, add to additional classes for generation + if (isGeneratableClass(fieldType)) { + additionalClasses.add(fieldType) + logger.debug("[GoSDK] Added additional class for generation: " + fieldType.simpleName) + return pointerPrefix + getViewStructName(fieldType) + } + + // For Java built-in types or interfaces, use interface{} + if (!fieldType.isPrimitive() && !fieldType.isEnum()) { + return "interface{}" + } + } + + if (type != null) { + if (type instanceof Class) { + Class clz = (Class) type + String goType = mapJavaTypeToGoType(clz, true) + if (goType != null) { + return goType + } + + // Check for self-reference (recursive type) - use pointer to break cycle + boolean isSelfReference = (currentGeneratingClass != null && clz == currentGeneratingClass) + String pointerPrefix = isSelfReference ? "*" : "" + + // Check if the class has @Inventory annotation + if (clz.isAnnotationPresent(Inventory.class)) { + inventories.add(clz) + return pointerPrefix + getViewStructName(clz) + } + + // For non-Inventory complex classes, add to additional classes for generation + if (isGeneratableClass(clz)) { + additionalClasses.add(clz) + logger.debug("[GoSDK] Added additional class for generation: " + clz.simpleName) + return pointerPrefix + getViewStructName(clz) + } + + // For Java built-in types or interfaces, use interface{} + return "interface{}" + } + if (type instanceof ParameterizedType) { ParameterizedType pt = (ParameterizedType) type - clz = ((Class) pt.rawType) - } else if (type instanceof TypeVariable) { - TypeVariable tType = (TypeVariable) type - clz = tType.genericDeclaration.getClass() + Class clz = (Class) pt.rawType + + // Check for self-reference + boolean isSelfReference = (currentGeneratingClass != null && clz == currentGeneratingClass) + String pointerPrefix = isSelfReference ? "*" : "" + + if (clz.isAnnotationPresent(Inventory.class)) { + inventories.add(clz) + return pointerPrefix + getViewStructName(clz) + } + + // For non-Inventory complex classes, add to additional classes + if (isGeneratableClass(clz)) { + additionalClasses.add(clz) + return pointerPrefix + getViewStructName(clz) + } + } + } + + return "interface{}" + } + + /** + * Check if a class is a generatable complex class (not primitive, not array, not Java built-in) + */ + private boolean isGeneratableClass(Class clz) { + if (clz == null) return false + if (clz.isPrimitive()) return false + if (clz.isEnum()) return false + if (clz.isInterface()) return false + if (clz.isArray()) return false // Exclude array types like byte[] + if (clz.name.startsWith("java.")) return false + if (clz.name.startsWith("javax.")) return false + if (clz.name.startsWith("[")) return false // Array internal representation + return true + } + + private String mapJavaTypeToGoType(Class javaType) { + return mapJavaTypeToGoType(javaType, false) + } + + private String mapJavaTypeToGoType(Class javaType, boolean forView) { + if (javaType == null) return null + + // Handle array types + if (javaType.isArray()) { + Class componentType = javaType.getComponentType() + if (componentType == byte.class || componentType == Byte.class) { + // Java byte is signed (-128 to 127), use []int8 instead of []byte (which is []uint8) + return "[]int8" + } + String elementType = mapJavaTypeToGoType(componentType, forView) + if (elementType != null) { + return "[]" + elementType + } + return "[]interface{}" + } + + switch (javaType) { + case String.class: + case Character.class: + case char.class: + return "string" + case Integer.class: + case int.class: + return "int" + case Long.class: + case long.class: + return "int64" + case Short.class: + case short.class: + return "int16" + case Byte.class: + case byte.class: + return "int8" + case Float.class: + case float.class: + return "float32" + case Double.class: + case double.class: + return "float64" + case Boolean.class: + case boolean.class: + return "bool" + case Date.class: + case java.sql.Timestamp.class: + // Normalize to time.Time (replace legacy ZStackTime) + return "time.Time" + default: + if (javaType.isEnum()) { + return "string" + } + return null + } + } + + /** + * Generate other_actions.go for miscellaneous APIs + */ + private SdkFile generateOtherActionsFile() { + logger.warn("[GoSDK] Generating other_actions.go...") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/client/" + sdkFile.fileName = "other_actions.go" + + // Collect all method code first to check if fmt is needed + StringBuilder methodsContent = new StringBuilder() + + allApiTemplates.each { GoApiTemplate template -> + if (template.isActionGrouped) return + + // Skip methods that are manually maintained in client.go + String apiName = template.getApiMsgClazz()?.simpleName ?: "" + if (apiName.contains("ValidateSession") || apiName.contains("LogIn") || apiName.contains("Login")) { + logger.debug("[GoSDK] Skipping manually maintained API in other_actions: " + apiName) + template.isActionGrouped = true + return + } + + // Check for duplicate method names (including Get methods from Query APIs) + Set methodNames = template.getGeneratedMethodNames() + boolean hasDuplicate = methodNames.any { generatedClientMethods.contains(it) } + + if (hasDuplicate) { + logger.warn("[GoSDK] Skipping duplicate method in other_actions: ${template.clzName} (methods ${methodNames} already generated)") + template.isActionGrouped = true + return + } + + String code = template.generateMethodCode() + if (code != "" && code != null) { + methodsContent.append(code).append("\n") + methodNames.each { generatedClientMethods.add(it) } + template.isActionGrouped = true + GoApiTemplate.groupedApiNames.add(template.clzName) + logger.debug("[GoSDK] Grouped orphaned action: " + template.clzName) + } + } + + // Check if fmt is needed (for fmt.Sprintf in multi-placeholder paths) + boolean needsFmt = methodsContent.toString().contains("fmt.Sprintf") + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package client\n\n") + content.append("import (\n") + content.append("\t\"context\"\n") + if (needsFmt) { + content.append("\t\"fmt\"\n") + } + content.append("\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param\"\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view\"\n") + content.append(")\n\n") + content.append("var _ = param.BaseParam{} // avoid unused import\n") + content.append("var _ view.MapView // avoid unused import\n\n") + + // Append the collected methods + content.append(methodsContent.toString()) + + sdkFile.content = content.toString() + return sdkFile + } + + /** + * Generate other_views.go for miscellaneous APIs (Catch-all) + * This catches ALL response classes that haven't been generated yet, regardless of resource grouping + */ + private SdkFile generateOtherViewsFile() { + logger.warn("[GoSDK] Generating other_views.go...") + logger.warn("[GoSDK] Total API templates to check: " + allApiTemplates.size()) + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/view/" + sdkFile.fileName = "other_views.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package view\n\n") + content.append("import \"time\"\n\n") + content.append("var _ = time.Now() // avoid unused import\n\n") + + int addedCount = 0 + Set processedViews = new HashSet<>() + + // Generate response views for ALL APIs, catching anything missed + allApiTemplates.each { GoApiTemplate template -> + Class responseClass = template.getResponseClass() + if (responseClass == null) { + return + } + + String viewStructName = getViewStructName(responseClass) + + // Skip if already processed in this pass + if (processedViews.contains(viewStructName)) { + return + } + processedViews.add(viewStructName) + + // Skip if already generated in resource views + if (generatedViewStructs.contains(viewStructName)) { + logger.debug("[GoSDK] View ${viewStructName} already generated (from ${template.clzName})") + return + } + + // Generate the response view + String structCode = template.generateResponseViewCode() + if (structCode != null && structCode != "") { + generatedViewStructs.add(viewStructName) + content.append(structCode) + addedCount++ + logger.warn("[GoSDK] Added view to other_views.go: " + viewStructName + " (from ${template.clzName})") } else { - clz = (Class) type + logger.warn("[GoSDK] Failed to generate view: " + viewStructName + " (from ${template.clzName})") } - inventories.add(clz) } - return value + + logger.warn("[GoSDK] Finished other_views.go, added ${addedCount} views") + + sdkFile.content = content.toString() + return sdkFile } - private static String generateValidatorString(Field field, Map overriden) { - def value = new StringBuilder("") - if (!field.isAnnotationPresent(APIParam.class)) { - return value + /** + * Generate other_params.go for miscellaneous APIs + */ + private SdkFile generateOtherParamsFile() { + logger.warn("[GoSDK] Generating other_params.go...") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/param/" + sdkFile.fileName = "other_params.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package param\n\n") + + Set> apiClasses = Platform.reflections.getTypesAnnotatedWith(RestRequest.class) + logger.warn("[GoSDK] Total APIs found for other_params: " + apiClasses.size()) + + int addedCount = 0 + int skippedCount = 0 + allApiTemplates.each { GoApiTemplate template -> + // Skip if already grouped in a resource-specific param file + if (template.isParamGrouped) { + skippedCount++ + return + } + + // Skip Login-related APIs (handled by login_params.go template) + String apiName = template.getApiMsgClazz()?.simpleName ?: "" + if (apiName.contains("LogIn") || apiName.contains("Login") || apiName.contains("ValidateSession")) { + logger.debug("[GoSDK] Skipping Login/Session API in other_params: " + apiName) + template.isParamGrouped = true + skippedCount++ + return + } + + // If it's not a query message AND it hasn't been generated individually + if (!template.isQueryMessage()) { + String paramStructName = template.getParamStructName() + String detailParamName = template.getDetailParamStructName() + + // Double-check: skip if already in generatedParamStructs (from grouped files) + if (!generatedParamStructs.contains(paramStructName)) { + String structCode = generateParamStruct(template.getApiMsgClazz(), paramStructName, detailParamName, template.getAt()) + if (structCode != "") { + generatedParamStructs.add(paramStructName) + generatedParamStructs.add(detailParamName) + content.append(structCode) + addedCount++ + logger.warn("[GoSDK] Added orphaned param to other_params.go: " + paramStructName + " (from ${template.getApiMsgClazz().simpleName})") + } + template.isParamGrouped = true + } else { + skippedCount++ + logger.debug("[GoSDK] Skipping already generated param: " + paramStructName) + template.isParamGrouped = true + } + } } - APIParam annotation = overriden.containsKey(field.name) ? overriden[field.name] : field.getAnnotation(APIParam.class) - if (!annotation.required()) { - return value + logger.warn("[GoSDK] Finished other_params.go, added ${addedCount} params, skipped ${skippedCount}") + + sdkFile.content = content.toString() + return sdkFile + } + + /** + * Revised generateViewFiles to include response Events/Replies + */ + private List generateViewFiles() { + def files = [] + def fileMap = [:] // Map fileName -> SdkFile for merging multiple inventories + + // Deduplicate inventories first - use LinkedHashSet to maintain order and remove duplicates + def uniqueInventories = new LinkedHashSet(inventories) + logger.warn("[GoSDK] Starting generateViewFiles: ${inventories.size()} inventories (${uniqueInventories.size()} unique)") + + // Use a list and index to handle dynamically added inventories + def activeInventories = new ArrayList(uniqueInventories) + int index = 0 + + while (index < activeInventories.size()) { + Class inventoryClass = activeInventories.get(index) + String prefix = inventoryClass.simpleName.replaceAll('Inventory$', '') + String structName = getViewStructName(inventoryClass) + String fileName = "${toSnakeCase(prefix)}_views.go" + + logger.warn("[GoSDK] Processing inventory [${index + 1}/${activeInventories.size()}]: ${inventoryClass.simpleName} -> ${prefix}") + + // Check if file already exists in map - if so, merge; if not, create new + def sdkFile + def content + boolean isNewFile = false + + if (fileMap.containsKey(fileName)) { + // File already exists, merge into it + sdkFile = fileMap[fileName] + content = new StringBuilder(sdkFile.content) + logger.warn("[GoSDK] Merging ${structName} into existing ${fileName}") + } else { + // Create new file + isNewFile = true + sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/view/" + sdkFile.fileName = fileName + + content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package view\n\n") + content.append("import \"time\"\n\n") + content.append("var _ = time.Now() // avoid unused import\n\n") + logger.warn("[GoSDK] Generating new view file for: ${structName} (${fileName})") + } + + // Add Inventory Struct (if not already generated) - do this for BOTH new and merged files + if (!generatedViewStructs.contains(structName)) { + generatedViewStructs.add(structName) + content.append(generateViewStruct(inventoryClass, structName)) + logger.warn("[GoSDK] Added inventory struct: ${structName}") + } else { + logger.warn("[GoSDK] Inventory struct ${structName} already exists, skipping duplicate") + } + + // Collect and group response views for this resource using the cache + allApiTemplates.each { GoApiTemplate template -> + if (template.isViewGrouped) return + + String resName = template.getResourceName() + // queryInventoryClass is populated for ALL APIs that return an inventory (not just Query APIs) + Class returnedInventory = template.getQueryInventoryClass() + + // Match by resourceName OR by returned inventory type + // Use exact match to avoid CdpPolicy matching Policy + boolean matchesByName = resName != null && resName == prefix + boolean matchesByInventory = returnedInventory != null && returnedInventory == inventoryClass + + if (matchesByName || matchesByInventory) { + if (matchesByInventory && !matchesByName) { + logger.warn("[GoSDK] Matched API ${template.clzName} to ${prefix} by returned inventory type (${returnedInventory.simpleName})") + } + + logger.warn("[GoSDK] Resource match: API ${template.clzName} (Resource: ${resName}, ActionType: '${template.getActionType()}') matches Inventory prefix: ${prefix}") + // Only standard actions (non-empty actionType) go into resource view files + if (template.getActionType() != "") { + Class responseClass = template.getResponseClass() + logger.warn("[GoSDK] Checking responseClass for ${template.clzName}: ${responseClass?.simpleName ?: 'null'}") + if (responseClass != null) { + String viewName = getViewStructName(responseClass) + logger.warn("[GoSDK] ViewName for ${template.clzName}: ${viewName}, already generated: ${generatedViewStructs.contains(viewName)}") + if (!generatedViewStructs.contains(viewName)) { + logger.warn("[GoSDK] Calling generateResponseViewCode for ${template.clzName}...") + String structCode = template.generateResponseViewCode() + logger.warn("[GoSDK] generateResponseViewCode returned ${structCode.length()} chars for ${viewName}") + if (structCode != "") { + generatedViewStructs.add(viewName) + content.append(structCode) + template.isViewGrouped = true + logger.warn("[GoSDK] Grouped ${viewName} into ${fileName} (from ${template.clzName}), content now ${content.length()} chars") + } else { + logger.warn("[GoSDK] Skipping ${viewName} - no view code generated (will be caught by other_views.go if needed)") + } + } else { + logger.warn("[GoSDK] ${viewName} already generated, marking template as grouped") + template.isViewGrouped = true + } + } + } else { + logger.warn("[GoSDK] Skipping ${template.clzName} - empty actionType") + } + } + } + + // Update file content and store in map + sdkFile.content = content.toString() + fileMap[fileName] = sdkFile + logger.warn("[GoSDK] Updated ${fileName}: content.length=${content.length()}, generatedViewStructs.size=${generatedViewStructs.size()}") + + // If this is a new file, add to output list + if (isNewFile) { + files.add(sdkFile) + logger.warn("[GoSDK] Added new file ${fileName} to output (${files.size()} total files)") + } else { + logger.warn("[GoSDK] Merged ${inventoryClass.simpleName} into existing ${fileName}") + } + + // Re-sync activeInventories if more were discovered - but avoid duplicates + inventories.each { Class newClz -> + if (!activeInventories.contains(newClz)) { + activeInventories.add(newClz) + logger.warn("[GoSDK] Discovered new inventory during processing: ${newClz.simpleName}") + } + } + index++ + } + + logger.warn("[GoSDK] Finished generateViewFiles: generated ${files.size()} view files") + return files + } + + /** + * Generate action files grouped by resource + */ + private List generateActionFiles() { + def files = [] + def activeInventories = new ArrayList(inventories) + + activeInventories.each { Class inventoryClass -> + String prefix = inventoryClass.simpleName.replaceAll('Inventory$', '') + String fileName = "${toSnakeCase(prefix)}_actions.go" + + // Collect all method code first to check if fmt is needed + StringBuilder methodsContent = new StringBuilder() + boolean hasActions = false + + allApiTemplates.each { GoApiTemplate template -> + if (template.isActionGrouped) return + + String resName = template.getResourceName() + // Use exact match to avoid CdpPolicy matching Policy + if (resName != null && resName == prefix) { + // Check for duplicate method names (including Get methods from Query APIs) + Set methodNames = template.getGeneratedMethodNames() + boolean hasDuplicate = methodNames.any { generatedClientMethods.contains(it) } + + if (hasDuplicate) { + logger.warn("[GoSDK] Skipping duplicate method: ${template.clzName} (methods ${methodNames} already generated)") + template.isActionGrouped = true + return + } + + methodsContent.append(template.generateMethodCode()) + methodNames.each { generatedClientMethods.add(it) } + template.isActionGrouped = true + GoApiTemplate.groupedApiNames.add(template.clzName) + hasActions = true + } + } + + if (hasActions) { + // Check if fmt is needed (for fmt.Sprintf in multi-placeholder paths) + boolean needsFmt = methodsContent.toString().contains("fmt.Sprintf") + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package client\n\n") + content.append("import (\n") + content.append("\t\"context\"\n") + if (needsFmt) { + content.append("\t\"fmt\"\n") + } + content.append("\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param\"\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view\"\n") + content.append(")\n\n") + content.append("var _ = param.BaseParam{} // avoid unused import\n") + content.append("var _ view.MapView // avoid unused import\n\n") + + // Append the collected methods + content.append(methodsContent.toString()) + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/client/" + sdkFile.fileName = fileName + sdkFile.content = content.toString() + files.add(sdkFile) + logger.warn("[GoSDK] Generated grouped action file: " + fileName) + } } - value.append('validate:"') - def strings = new ArrayList() - if (annotation.required()) { - strings.add('required') + return files + } + + /** + * Generate param files grouped by resource + */ + private List generateParamFiles() { + def files = [] + def activeInventories = new ArrayList(inventories) + + activeInventories.each { Class inventoryClass -> + String prefix = inventoryClass.simpleName.replaceAll('Inventory$', '') + String fileName = "${toSnakeCase(prefix)}_params.go" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n\n") + content.append("package param\n\n") + content.append("import \"time\"\n\n") + content.append("var _ = time.Now() // avoid unused import\n\n") + + boolean hasParams = false + allApiTemplates.each { GoApiTemplate template -> + if (template.isParamGrouped) return + + // Standard actions generate their params here + // We use isGrouped logic differently for params, or just check the resource + String resName = template.getResourceName() + // Use exact match to avoid CdpPolicy matching Policy + if (resName == prefix) { + if (!template.isQueryMessage()) { + String paramStructName = template.getParamStructName() + String detailParamName = template.getDetailParamStructName() + + if (!generatedParamStructs.contains(paramStructName)) { + content.append(generateParamStruct(template.getApiMsgClazz(), paramStructName, detailParamName, template.getAt())) + generatedParamStructs.add(paramStructName) + generatedParamStructs.add(detailParamName) + hasParams = true + template.isParamGrouped = true + } + } + } + } + + if (hasParams) { + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/param/" + sdkFile.fileName = fileName + sdkFile.content = content.toString() + files.add(sdkFile) + logger.warn("[GoSDK] Generated grouped param file: " + fileName) + } } - value.append(strings.join(",")) - value.append('"') - return value + return files } } diff --git a/rest/src/main/resources/scripts/GoTestTemplate.groovy b/rest/src/main/resources/scripts/GoTestTemplate.groovy new file mode 100644 index 00000000000..d9793d8f87a --- /dev/null +++ b/rest/src/main/resources/scripts/GoTestTemplate.groovy @@ -0,0 +1,551 @@ +package scripts + +import org.zstack.rest.sdk.SdkFile +import org.zstack.utils.Utils +import org.zstack.utils.logging.CLogger + +class GoTestTemplate { + private static final CLogger logger = Utils.getLogger(GoTestTemplate.class) + + private def inventoryGenerator // GoInventory instance + private def allApiTemplates + private def inventories + private Set generatedIntegrationFiles = new HashSet<>() + + GoTestTemplate(def inventoryGenerator, def allApiTemplates, def inventories) { + this.inventoryGenerator = inventoryGenerator + this.allApiTemplates = allApiTemplates + this.inventories = inventories + } + + List generate() { + def files = [] + logger.warn("[GoSDK-Test] Starting test generation...") + + // 1. Static base unit test file + files.add(generateBaseUnitTestFile()) + + // 2. Static base integration test file + files.add(generateBaseIntegrationTestFile()) + + // 2. Per-resource test files + def resourceMap = groupApisByResource() + logger.warn("[GoSDK-Test] Grouped ${resourceMap.size()} resources for test generation") + + resourceMap.each { String prefix, Map resourceInfo -> + String snakeName = toSnakeCase(prefix) + + // Unit tests + def paramTest = generateParamTestFile(prefix, snakeName, resourceInfo) + if (paramTest != null) files.add(paramTest) + + def viewTest = generateViewTestFile(prefix, snakeName, resourceInfo) + if (viewTest != null) files.add(viewTest) + + // TODO: client tests require parsing actual method signatures from GoApiTemplate + // Will be added in a follow-up iteration + // def clientTest = generateClientTestFile(prefix, snakeName, resourceInfo) + // if (clientTest != null) files.add(clientTest) + + // Integration tests (generated to pkg/integration_test/ to avoid conflicts) + def integrationTest = generateIntegrationTestFile(prefix, snakeName, resourceInfo) + if (integrationTest != null) files.add(integrationTest) + } + + logger.warn("[GoSDK-Test] Test generation complete. Generated ${files.size()} test files") + return files + } + + // ======================== Resource Grouping ======================== + + private Map groupApisByResource() { + def resourceMap = [:] + + inventories.each { Class inventoryClass -> + String prefix = inventoryClass.simpleName.replaceAll('Inventory\$', '') + if (!resourceMap.containsKey(prefix)) { + resourceMap[prefix] = [ + inventoryClass: inventoryClass, + viewStructName: inventoryGenerator.getViewStructName(inventoryClass), + templates: [] + ] + } + } + + allApiTemplates.each { template -> + String resName = template.getResourceName() + if (resName != null && resourceMap.containsKey(resName)) { + resourceMap[resName].templates.add(template) + } + } + + // Filter out resources with no templates + return resourceMap.findAll { k, v -> !v.templates.isEmpty() } + } + + // ======================== Base Unit Test ======================== + + private SdkFile generateBaseUnitTestFile() { + def content = new StringBuilder() + content.append('''\ +// Copyright (c) ZStack.io, Inc. +// Auto-generated test infrastructure. DO NOT EDIT. + +package unit_test + +import ( +\t"encoding/json" +\t"fmt" +\t"net/http" +\t"net/http/httptest" +\t"strings" +\t"testing" +\t"time" + +\t"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/client" +) + +// newMockClient creates a ZSClient backed by an httptest server. +// The handler receives all HTTP requests and can assert on method/path/body. +func newMockClient(handler http.HandlerFunc) (*client.ZSClient, func()) { +\tserver := httptest.NewServer(handler) +\t// Parse host and port from server URL +\taddr := server.Listener.Addr().String() +\tparts := strings.SplitN(addr, ":", 2) +\thost := parts[0] +\tport := 80 +\tif len(parts) == 2 { +\t\tfmt.Sscanf(parts[1], "%d", &port) +\t} + +\tconfig := client.NewZSConfig(host, port, "") +\tconfig.LoginAccount("admin", "password") +\tcli := client.NewZSClient(config) +\tcli.LoadSession("mock-session-id") +\treturn cli, server.Close +} + +// mockInventoryResponse builds a JSON response wrapping data in {"inventory": ...} +func mockInventoryResponse(data map[string]interface{}) []byte { +\tresp := map[string]interface{}{"inventory": data} +\tb, _ := json.Marshal(resp) +\treturn b +} + +// mockInventoriesResponse builds a JSON response wrapping data in {"inventories": [...]} +func mockInventoriesResponse(items ...map[string]interface{}) []byte { +\tresp := map[string]interface{}{"inventories": items} +\tb, _ := json.Marshal(resp) +\treturn b +} + +// stringPtr returns a pointer to the given string. +func stringPtr(s string) *string { +\treturn &s +} + +// timePtr parses a time string and returns a pointer. +func timePtr(s string) *time.Time { +\tt, _ := time.Parse(time.RFC3339, s) +\treturn &t +} + +// assertEqual is a simple test helper. +func assertEqual(t *testing.T, expected, actual interface{}) { +\tt.Helper() +\tif expected != actual { +\t\tt.Errorf("expected %v, got %v", expected, actual) +\t} +} + +// assertNoError fails the test if err is not nil. +func assertNoError(t *testing.T, err error) { +\tt.Helper() +\tif err != nil { +\t\tt.Fatalf("unexpected error: %v", err) +\t} +} + +// assertContains checks that s contains substr. +func assertContains(t *testing.T, s, substr string) { +\tt.Helper() +\tif !strings.Contains(s, substr) { +\t\tt.Errorf("expected %q to contain %q", s, substr) +\t} +} +''') + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "base_unit_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Param Tests ======================== + + private SdkFile generateParamTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + def paramTemplates = templates.findAll { t -> + !t.isQueryMessage() && t.getActionType() in ['Create', 'Update', 'Add'] + } + if (paramTemplates.isEmpty()) return null + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated param tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"encoding/json\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + + paramTemplates.each { template -> + String paramStruct = template.getParamStructName() + String detailStruct = template.getDetailParamStructName() + String methodName = template.clzName + + // Marshal test - zero value should produce valid JSON + content.append("func Test${methodName}Param_MarshalJSON(t *testing.T) {\n") + content.append("\tp := param.${paramStruct}{}\n") + content.append("\tdata, err := json.Marshal(p)\n") + content.append("\tassertNoError(t, err)\n") + content.append("\tif len(data) == 0 {\n") + content.append("\t\tt.Fatal(\"marshaled JSON should not be empty\")\n") + content.append("\t}\n") + content.append("\t// Verify it's valid JSON\n") + content.append("\tvar raw map[string]interface{}\n") + content.append("\tassertNoError(t, json.Unmarshal(data, &raw))\n") + content.append("}\n\n") + + // Unmarshal test - minimal JSON should parse without error + content.append("func Test${methodName}Param_UnmarshalJSON(t *testing.T) {\n") + content.append("\tjsonStr := `{}`\n") + content.append("\tvar p param.${paramStruct}\n") + content.append("\terr := json.Unmarshal([]byte(jsonStr), &p)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + } + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_param_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== View Tests ======================== + + private SdkFile generateViewTestFile(String prefix, String snakeName, Map resourceInfo) { + String viewStructName = resourceInfo.viewStructName + if (viewStructName == null) return null + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated view tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"encoding/json\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view\"\n") + content.append(")\n\n") + + // InventoryView unmarshal test + content.append("func Test${viewStructName}_UnmarshalJSON(t *testing.T) {\n") + content.append("\tjsonStr := `{\n") + content.append("\t\t\"uuid\": \"test-uuid-001\",\n") + content.append("\t\t\"name\": \"test-${snakeName}\",\n") + content.append("\t\t\"createDate\": \"2024-01-01T00:00:00.000+08:00\",\n") + content.append("\t\t\"lastOpDate\": \"2024-01-01T00:00:00.000+08:00\"\n") + content.append("\t}`\n") + content.append("\tvar v view.${viewStructName}\n") + content.append("\terr := json.Unmarshal([]byte(jsonStr), &v)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + + // Empty JSON should not error + content.append("func Test${viewStructName}_UnmarshalEmpty(t *testing.T) {\n") + content.append("\tvar v view.${viewStructName}\n") + content.append("\terr := json.Unmarshal([]byte(`{}`), &v)\n") + content.append("\tassertNoError(t, err)\n") + content.append("}\n\n") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_view_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Client Tests ======================== + + private SdkFile generateClientTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + if (templates.isEmpty()) return null + + String viewStructName = resourceInfo.viewStructName + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated client tests. DO NOT EDIT.\n\n") + content.append("package unit_test\n\n") + content.append("import (\n") + content.append("\t\"net/http\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + content.append("var _ = param.BaseParam{} // avoid unused import\n\n") + + templates.each { template -> + String actionType = template.getActionType() + String methodName = template.clzName + + if (template.isQueryMessage()) { + // Query test + content.append(generateQueryClientTest(prefix, methodName, snakeName)) + // Get test (derived from Query) + String getMethodName = methodName.replaceFirst('^Query', 'Get') + content.append(generateGetClientTest(prefix, getMethodName, snakeName)) + } else if (actionType == 'Create' || actionType == 'Add') { + content.append(generateCreateClientTest(prefix, methodName, snakeName, template.getParamStructName())) + } else if (actionType == 'Update' || actionType == 'Change') { + content.append(generateUpdateClientTest(prefix, methodName, snakeName, template.getParamStructName())) + } else if (actionType == 'Delete') { + content.append(generateDeleteClientTest(prefix, methodName, snakeName)) + } + } + + if (content.toString().count("func Test") == 0) return null + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/unit_test/" + sdkFile.fileName = "${snakeName}_client_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + private String generateQueryClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodGet, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoriesResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() +ctx := context.Background() +\tqueryParam := param.NewQueryParam() +\tresult, err := cli.${methodName}(ctx, &queryParam) +\tassertNoError(t, err) +\tif len(result) == 0 { +\t\tt.Fatal("expected at least one result") +\t} +\tassertEqual(t, "test-uuid-001", result[0].UUID) +} + +""" + } + + private String generateGetClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodGet, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoriesResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}("test-uuid-001") +\tassertNoError(t, err) +\tassertEqual(t, "test-uuid-001", result.UUID) +} + +""" + } + + private String generateCreateClientTest(String prefix, String methodName, String snakeName, String paramStruct) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodPost, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoryResponse(map[string]interface{}{ +\t\t\t"uuid": "new-uuid-001", +\t\t\t"name": "test-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}(param.${paramStruct}{}) +\tassertNoError(t, err) +\tassertEqual(t, "new-uuid-001", result.UUID) +} + +""" + } + + private String generateUpdateClientTest(String prefix, String methodName, String snakeName, String paramStruct) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodPut, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write(mockInventoryResponse(map[string]interface{}{ +\t\t\t"uuid": "test-uuid-001", +\t\t\t"name": "updated-${snakeName}", +\t\t})) +\t}) +\tdefer cleanup() + +\tresult, err := cli.${methodName}("test-uuid-001", param.${paramStruct}{}) +\tassertNoError(t, err) +\tassertEqual(t, "test-uuid-001", result.UUID) +} + +""" + } + + private String generateDeleteClientTest(String prefix, String methodName, String snakeName) { + return """\ +func Test${methodName}_Client(t *testing.T) { +\tcli, cleanup := newMockClient(func(w http.ResponseWriter, r *http.Request) { +\t\tassertEqual(t, http.MethodDelete, r.Method) +\t\tw.WriteHeader(http.StatusOK) +\t\tw.Write([]byte(`{}`)) +\t}) +\tdefer cleanup() + +\terr := cli.${methodName}("test-uuid-001", param.DeleteModePermissive) +\tassertNoError(t, err) +} + +""" + } + + // ======================== Integration Tests ======================== + + private SdkFile generateBaseIntegrationTestFile() { + def content = new StringBuilder() + content.append('''\ +// Copyright (c) ZStack.io, Inc. +// Auto-generated integration test infrastructure. DO NOT EDIT. + +package integration_test + +import ( +\t"context" +\t"os" +\t"testing" + +\t"github.com/kataras/golog" +\t"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/client" +) + +const ( +\tdefaultHostname = "localhost" +\tdefaultAccount = "admin" +\tdefaultPassword = "password" +) + +var testCli *client.ZSClient + +func TestMain(m *testing.M) { +\tctx := context.Background() +\thostname := os.Getenv("ZSTACK_HOST") +\tif hostname == "" { +\t\thostname = defaultHostname +\t} +\taccount := os.Getenv("ZSTACK_ACCOUNT") +\tif account == "" { +\t\taccount = defaultAccount +\t} +\tpassword := os.Getenv("ZSTACK_PASSWORD") +\tif password == "" { +\t\tpassword = defaultPassword +\t} + +\tconfig := client.DefaultZSConfig(hostname). +\t\tLoginAccount(account, password). +\t\tDebug(true) +\ttestCli = client.NewZSClient(config) + +\t_, err := testCli.Login(ctx) +\tif err != nil { +\t\tgolog.Errorf("Integration test login failed: %v", err) +\t\tos.Exit(1) +\t} +\tdefer testCli.Logout(ctx) + +\tos.Exit(m.Run()) +} +''') + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/integration_test/" + sdkFile.fileName = "base_test.go" + sdkFile.content = content.toString() + return sdkFile + } + + private SdkFile generateIntegrationTestFile(String prefix, String snakeName, Map resourceInfo) { + def templates = resourceInfo.templates + + // Only generate for resources that have a Query API + def queryTemplate = templates.find { it.isQueryMessage() } + if (queryTemplate == null) return null + + String fileName = "${snakeName}_query_test.go" + + // Track generated filenames to avoid collisions within this run + if (generatedIntegrationFiles.contains(fileName)) { + logger.warn("[GoSDK-Test] Skipping duplicate integration test file: ${fileName}") + return null + } + generatedIntegrationFiles.add(fileName) + + String methodName = queryTemplate.clzName // e.g. "QueryCluster" + + def content = new StringBuilder() + content.append("// Copyright (c) ZStack.io, Inc.\n") + content.append("// Auto-generated integration tests. DO NOT EDIT.\n\n") + content.append("package integration_test\n\n") + content.append("import (\n") + content.append("\t\"context\"\n") + content.append("\t\"testing\"\n\n") + content.append("\t\"github.com/kataras/golog\"\n\n") + content.append("\t\"github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param\"\n") + content.append(")\n\n") + + // Query test + content.append("func Test${methodName}(t *testing.T) {\n") + content.append("\tctx := context.Background()\n") + content.append("\tqueryParam := param.NewQueryParam()\n") + content.append("\tresult, err := testCli.${methodName}(ctx, &queryParam)\n") + content.append("\tif err != nil {\n") + content.append("\t\tt.Errorf(\"Test${methodName} error: %v\", err)\n") + content.append("\t\treturn\n") + content.append("\t}\n") + content.append("\tgolog.Infof(\"${methodName} result count: %d\", len(result))\n") + content.append("}\n\n") + + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/integration_test/" + sdkFile.fileName = fileName + sdkFile.content = content.toString() + return sdkFile + } + + // ======================== Utilities ======================== + + private String toSnakeCase(String name) { + return name.replaceAll('([a-z])([A-Z])', '$1_$2').toLowerCase() + } +} diff --git a/rest/src/main/resources/scripts/RestDocumentationGenerator.groovy b/rest/src/main/resources/scripts/RestDocumentationGenerator.groovy index e69a4db1b54..834d86fe99b 100755 --- a/rest/src/main/resources/scripts/RestDocumentationGenerator.groovy +++ b/rest/src/main/resources/scripts/RestDocumentationGenerator.groovy @@ -42,6 +42,7 @@ import javax.xml.bind.JAXBContext import javax.xml.bind.JAXBException import javax.xml.bind.Unmarshaller import java.lang.reflect.Field +import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Modifier import java.nio.file.Paths @@ -74,8 +75,8 @@ class RestDocumentationGenerator implements DocumentGenerator { void init() { try { loadSystemProperties() - loadConfigFromXml() parseGlobalConfigFields() + loadConfigFromXml() loadConfigFromJava() link() } catch (IllegalArgumentException ie) { @@ -105,7 +106,6 @@ class RestDocumentationGenerator implements DocumentGenerator { try { def bind = field.getAnnotation(BindResourceConfig.class) def validator = field.getAnnotation(GlobalConfigValidation.class) - def configBef = field.getAnnotation(GlobalConfigDef.class) GlobalConfig config = (GlobalConfig) field.get(null) if (config == null) { throw new CloudRuntimeException(String.format("GlobalConfigDefinition[%s] " + @@ -133,20 +133,14 @@ class RestDocumentationGenerator implements DocumentGenerator { + validator.validValues().join(", ") + "}") continue } - - GlobalConfig conf = configs.get(config.getIdentity()) - boolean isInteger = (conf != null && conf.type == Integer.class.getName()) || - (configBef != null && configBef.type() == Integer.class) - if (validator.numberGreaterThan() == Long.MIN_VALUE && validator.numberLessThan() == Long.MAX_VALUE) { if (validator.inNumberRange().length != 0) { validatorMap.put(config.getIdentity(), "[" + validator.inNumberRange().join(" ,") + "]") } else { - validatorMap.put(config.getIdentity(), - generateRangeString(isInteger, validator.numberGreaterThan(), - validator.numberLessThan())) + validatorMap.put(config.getIdentity(), "[" + Long.MIN_VALUE + ", " + + Long.MAX_VALUE + "]") } continue } @@ -163,9 +157,16 @@ class RestDocumentationGenerator implements DocumentGenerator { + validator.numberGreaterThan().toString() + ", " + validator.numberLessThan().toString() + "]") } else { - validatorMap.put(config.getIdentity(), - generateRangeString(isInteger, validator.numberGreaterThan(), - validator.numberLessThan())) + if (validator.numberLessThan() != Long.MAX_VALUE) { + validatorMap.put(config.getIdentity(), "[" + + Long.MIN_VALUE + ", " + + validator.numberLessThan().toString() + "]") + } + if (validator.numberGreaterThan() != Long.MIN_VALUE) { + validatorMap.put(config.getIdentity(), "[" + + validator.numberGreaterThan().toString() + ", " + + Long.MAX_VALUE + "]") + } } } catch (IllegalAccessException e) { @@ -176,11 +177,6 @@ class RestDocumentationGenerator implements DocumentGenerator { } } - String generateRangeString(boolean isInteger, long greaterThan, long lessThan) { - String typeRange = isInteger ? "${Integer.MIN_VALUE},${Integer.MAX_VALUE}" : "${Long.MIN_VALUE},${Long.MAX_VALUE}" - return "[" + (greaterThan != Long.MIN_VALUE ? greaterThan : typeRange.split(",")[0]) + ", " + - (lessThan != Long.MAX_VALUE ? lessThan : typeRange.split(",")[1]) + "]" - } private void loadConfigFromJava() { for (Field field : globalConfigFields) { @@ -533,7 +529,7 @@ class RestDocumentationGenerator implements DocumentGenerator { Set apiClasses = getRequestRequestApiSet() apiClasses.each { - println("generating doc template for class ${it}") + logger.info("generating doc template for class ${it}") def tmp = new ApiRequestDocTemplate(it) tmp.generateDocFile(mode) } @@ -554,6 +550,9 @@ class RestDocumentationGenerator implements DocumentGenerator { String specifiedClasses = System.getProperty("classes") + apiClasses.forEach {it -> + logger.info("apiClasses 1: ${it}".toString()) + } if (specifiedClasses != null) { def classes = specifiedClasses.split(",") as List apiClasses = apiClasses.findAll { @@ -567,6 +566,10 @@ class RestDocumentationGenerator implements DocumentGenerator { } } + apiClasses.forEach {it -> + logger.info("apiClasses 2: ${it}".toString()) + } + return apiClasses } @@ -583,10 +586,6 @@ class RestDocumentationGenerator implements DocumentGenerator { def errInfo = [] getRequestRequestApiSet().each { - if (it.isInterface() || Modifier.isAbstract(it.getModifiers())) { - return - } - def docPath = getDocTemplatePathFromClass(it) logger.info("processing ${docPath}") try { @@ -833,6 +832,7 @@ class RestDocumentationGenerator implements DocumentGenerator { && md.globalConfig.valueRange == "{true, false}") if (validatorString != null || !useBooleanValidator) { logger.info("valueRange of ${mdPath} is not latest") + logger.info("valueRange = ${md.globalConfig.valueRange} validatorString = ${validatorString}") flag = false } } @@ -1251,7 +1251,13 @@ ${category(err)} } Doc createDoc(String docTemplatePath) { - Script script = new GroovyShell().parse(new File(docTemplatePath)) + Script script + try { + script = new GroovyShell().parse(new File(docTemplatePath)) + } catch (FileNotFoundException e) { + throw new CloudRuntimeException("cannot find doc template file[path:${docTemplatePath}]", e) + } + return createDocFromGroobyScript(script) } @@ -1315,11 +1321,11 @@ ${cols.join("\n")} String actionName = getSdkActionName() - List cols = ["action = ${actionName}()".toString()] + def cols = ["${actionName} action = ${actionName}()"] List conds = getQueryConditionExampleOfTheClass(clz) conds = conds.collect { return "\"" + it + "\""} - cols.add("action.conditions = [${conds.join(",")}]".toString()) - cols.add("action.sessionId = \"b86c9016b4f24953a9edefb53ca0678c\"") + cols.add("action.conditions = [${conds.join(",")}]") + cols.add("""action.sessionId = "b86c9016b4f24953a9edefb53ca0678c\"""") cols.add("res = action.call()") return """\ @@ -1381,44 +1387,6 @@ ${examples.join("\n")} @Override String generate() { - def urlScript = "" - def headerScript = "" - def requestExampleScript = "" - def curlExampleScript = "" - def paramsScript = "" - def responseDescScript = "" - def responseExampleScript = "" - def javaSdkScript = "" - def pythonSdkScript = "" - - try { - urlScript = url() - } catch (Exception e) { logger.warn("failed to generate url of ${doc._title}", e) } - try { - headerScript = headers() - } catch (Exception e) { logger.warn("failed to generate header of ${doc._title}", e) } - try { - requestExampleScript = requestExample() - } catch (Exception e) { logger.warn("failed to generate requestExample of ${doc._title}", e) } - try { - curlExampleScript = curlExample() - } catch (Exception e) { logger.warn("failed to generate curlExample of ${doc._title}", e) } - try { - paramsScript = params() - } catch (Exception e) { logger.warn("failed to generate params of ${doc._title}", e) } - try { - responseDescScript = responseDesc() - } catch (Exception e) { logger.warn("failed to generate responseDesc of ${doc._title}", e) } - try { - responseExampleScript = responseExample() - } catch (Exception e) { logger.warn("failed to generate responseExample of ${doc._title}", e) } - try { - javaSdkScript = javaSdk() - } catch (Exception e) { logger.warn("failed to generate javaSdk of ${doc._title}", e) } - try { - pythonSdkScript = pythonSdk() - } catch (Exception e) { logger.warn("failed to generate pythonSdk of ${doc._title}", e) } - return """\ ## ${doc._category} - ${doc._title} @@ -1426,31 +1394,31 @@ ${doc._desc} ## API请求 -${urlScript} +${url()} -${headerScript} +${headers()} -${requestExampleScript} +${requestExample()} -${curlExampleScript} +${curlExample()} -${paramsScript} +${params()} ## API返回 -${responseDescScript} +${responseDesc()} -${responseExampleScript} +${responseExample()} ## SDK示例 ### Java SDK -${javaSdkScript} +${javaSdk()} ### Python SDK -${pythonSdkScript} +${pythonSdk()} """ } } @@ -1590,7 +1558,18 @@ ${table.join("\n")} throw new CloudRuntimeException("__example__() of ${clz.name} must be declared as a static method") } - def example = m.invoke(null) + def example + try { + example = m.invoke(null) + } catch (InvocationTargetException e) { + Throwable target = e.getTargetException() + throw new CloudRuntimeException( + "cannot generate the markdown document for the class[${clz.name}], the __example__() method has an error: ${target?.message}", + target + ) + + } + DocUtils.removeApiUuidMap(example.class.name) @@ -1598,7 +1577,12 @@ ${table.join("\n")} example.validate() } - LinkedHashMap map = JSONObjectUtil.rehashObject(example, LinkedHashMap.class) + LinkedHashMap map + try { + map = JSONObjectUtil.rehashObject(example, LinkedHashMap.class) + } catch (IllegalStateException e) { + throw new CloudRuntimeException("cannot generate the markdown document for the class[${clz.name}], the __example__() method has an error: ${e.getMessage()}") + } List apiFields = getApiFieldsOfClass(clz) @@ -1619,6 +1603,7 @@ ${table.join("\n")} } catch (NoSuchMethodException e) { //throw new CloudRuntimeException("class[${clz.name}] doesn't have static __example__ method", e) logger.warn("class[${clz.name}] doesn't have static __example__ method") + return null } } @@ -1647,8 +1632,18 @@ ${table.join("\n")} curl.add("-X ${at.method()}") Map allFields = getApiExampleOfTheClass(clz) + if (allFields == null) { + logger.warn("No __example__ method found for class ${clz.name}, skipping curl example") + return "" + } - String urlPath = substituteUrl("${RestConstants.API_VERSION}${it}", allFields) + String urlPath + try { + urlPath = substituteUrl("${RestConstants.API_VERSION}${it}", allFields) + } catch (CloudRuntimeException e) { + logger.warn("Failed to substituteUrl for class ${clz.name} url ${it}, ${e.getMessage()}") + throw e + } if (!queryString) { def apiFields = getRequestBody() @@ -1726,6 +1721,9 @@ ${examples.join("\n")} // the API has a body Map apiFields = getApiExampleOfTheClass(clz) + if (apiFields == null) { + throw new CloudRuntimeException("cannot find the example of the class[${clz.name}]") + } List urlVars = getVarNamesFromUrl(at.path()) apiFields = apiFields.findAll { k, v -> !urlVars.contains(k) } return [(paramName): apiFields] @@ -1827,20 +1825,20 @@ ${dmd.generate()} return "Python SDK未能自动生成" } - List cols = ["action = ${actionName}()"] + def cols = ["${actionName} action = ${actionName}()"] cols.addAll(apiFields.collect { k, v -> if (v instanceof String) { - return "action.${k} = \"${v}\"".toString() + return """action.${k} = "${v}\"""" } else { - return "action.${k} = ${v}".toString() + return "action.${k} = ${v}" } }) if (!clz.isAnnotationPresent(SuppressCredentialCheck.class)) { - cols.add("action.sessionId = \"b86c9016b4f24953a9edefb53ca0678c\"") + cols.add("""action.sessionId = "b86c9016b4f24953a9edefb53ca0678c\"""") } - cols.add("res = action.call()") + cols.add("${actionName}.Result res = action.call()") return """\ ``` @@ -1905,77 +1903,43 @@ ${cols.join("\n")} } String generate() { - def urlScript = "" - def headerScript = "" - def requestExampleScript = "" - def curlExampleScript = "" - def paramsScript = "" - def responseDescScript = "" - def responseExampleScript = "" - def javaSdkScript = "" - def pythonSdkScript = "" - - try { - urlScript = url() - } catch (Exception e) { logger.warn("failed to generate url of ${doc._title}", e) } try { - headerScript = headers() - } catch (Exception e) { logger.warn("failed to generate header of ${doc._title}", e) } - try { - requestExampleScript = requestExample() - } catch (Exception e) { logger.warn("failed to generate requestExample of ${doc._title}", e) } - try { - curlExampleScript = curlExample() - } catch (Exception e) { logger.warn("failed to generate curlExample of ${doc._title}", e) } - try { - paramsScript = params() - } catch (Exception e) { logger.warn("failed to generate params of ${doc._title}", e) } - try { - responseDescScript = responseDesc() - } catch (Exception e) { logger.warn("failed to generate responseDesc of ${doc._title}", e) } - try { - responseExampleScript = responseExample() - } catch (Exception e) { logger.warn("failed to generate responseExample of ${doc._title}", e) } - try { - javaSdkScript = javaSdk() - } catch (Exception e) { logger.warn("failed to generate javaSdk of ${doc._title}", e) } - try { - pythonSdkScript = pythonSdk() - } catch (Exception e) { logger.warn("failed to generate pythonSdk of ${doc._title}", e) } - - return """\ + return """\ ## ${doc._category} - ${doc._title} ${doc._desc} ## API请求 -${urlScript} +${url()} -${headerScript} +${headers()} -${requestExampleScript} +${requestExample()} -${curlExampleScript} +${curlExample()} -${paramsScript} +${params()} ## API返回 -${responseDescScript} +${responseDesc()} -${responseExampleScript} +${responseExample()} ## SDK示例 ### Java SDK -${javaSdkScript} +${javaSdk()} ### Python SDK -${pythonSdkScript} +${pythonSdk()} """ + } catch (Exception e) { + logger.warn(e.message, e) + } } } @@ -2028,13 +1992,13 @@ ${pythonSdkScript} // generate dependent tables refs.each { - if (it._clz.name.startsWith("java.")) { - return "" - } - String txt = resolvedRefs[it._clz] if (txt == null) { + if (it._clz == null) { + throw new CloudRuntimeException("cannot find the class[${it._name}]") + } + String path = getDocTemplatePathFromClass(it._clz) Doc refDoc = createDoc(path) def dmd = new DataStructMarkDown(it._clz, refDoc) @@ -2074,6 +2038,7 @@ ${txt} char.class, String.class, Enum.class, + byte[].class ] class ApiResponseDocTemplate { @@ -2144,7 +2109,7 @@ ${txt} } if (at.allTo() != "") { - fsToAdd[at.allTo()] = getFieldRecursively(responseClass, at.allTo()) + fsToAdd[at.allTo()] = responseClass.getDeclaredField(at.allTo()) supplementFields('success', responseClass, at) supplementFields('error', responseClass, at) } else if (at.fieldsTo().length == 1 && at.fieldsTo()[0] == "all") { @@ -2167,20 +2132,6 @@ ${txt} } - static Field getFieldRecursively(Class clazz, String fieldName) { - try { - return clazz.getDeclaredField(fieldName) - } catch (NoSuchFieldException e) { - Class superClass = clazz.getSuperclass() - - if (superClass != null && superClass != Object.class) { - return getFieldRecursively(superClass, fieldName) - } else { - throw e - } - } - } - void supplementFields(String fieldName, Class clz, RestResponse at){ if(!at.fieldsTo().contains(fieldName)){ fsToAdd.put(fieldName, FieldUtils.getField(fieldName, clz)) @@ -2196,11 +2147,11 @@ ${txt} \t\tname "${n}" \t\tdesc "${desc == null ? "" : desc}" \t\ttype "${type}" -\t\tsince "${projectVersion}" +\t\tsince "${projectVersion != null ? projectVersion : "0.6"}" \t}""" } - String createRef(String n, String path, String desc, String type, Class clz) { + String createRef(String n, String path, String desc, String type, Class clz, Boolean overrideDesc=null) { DebugUtils.Assert(!PRIMITIVE_TYPES.contains(clz), "${clz.name} is a primitive class!!!") if (!ErrorCode.getClass().isAssignableFrom(clz)) { laterResolveClasses.add(clz) @@ -2210,9 +2161,9 @@ ${txt} return """\tref { \t\tname "${n}" \t\tpath "${path}" -\t\tdesc "${desc}" +\t\tdesc "${desc}"${overrideDesc != null ? ",${overrideDesc}" : ""} \t\ttype "${type}" -\t\tsince "${projectVersion}" +\t\tsince "${projectVersion != null ? projectVersion : "0.6"}" \t\tclz ${clz.simpleName}.class \t}""" } @@ -2244,10 +2195,12 @@ ${txt} } } else { String desc = null + Boolean overrideDesc = null if(ErrorCode.isAssignableFrom(v.type)){ desc = "错误码,若不为null,则表示操作失败, 操作成功时该字段为null" + overrideDesc = false } - fieldStrings.add(createRef("${k}", "${responseClass.canonicalName}.${v.name}", desc, v.type.simpleName, v.type)) + fieldStrings.add(createRef("${k}", "${responseClass.canonicalName}.${v.name}", desc, v.type.simpleName, v.type, overrideDesc)) } } @@ -2359,13 +2312,6 @@ ${fieldStr} l.add("\"${it}\"") } values = "values (${l.join(",")})" - } else if (ap != null && ap.validEnums().length != 0) { - def l = [] - def validValues = CollectionUtils.valuesForEnums(ap.validEnums()).collect(Collectors.toList()) - validValues.each { - l.add("\"${it}\"") - } - values = "values (${l.join(",")})" } String desc = MUTUAL_FIELDS.get(af.name) @@ -2386,21 +2332,20 @@ ${fieldStr} location = LOCATION_BODY } - def col = """\t\t\t\tcolumn { + cols.add("""\t\t\t\tcolumn { \t\t\t\t\tname "${af.name}" \t\t\t\t\tenclosedIn "${enclosedIn}" \t\t\t\t\tdesc "${desc == null ? "" : desc}" \t\t\t\t\tlocation "${location}" \t\t\t\t\ttype "${af.type.simpleName}" \t\t\t\t\toptional ${ap == null ? true : !ap.required()} -\t\t\t\t\tsince "${projectVersion}" -""" +\t\t\t\t\tsince "${projectVersion != null ? projectVersion : "0.6"}" +""") if (values != null) { - col += "\t\t\t\t\t${values}\n" + cols.add("\t\t\t\t\t${values}") } - col += "\t\t\t\t}" - cols.add(col) + cols.add("\t\t\t\t}") } if (cols.isEmpty()) { @@ -2440,7 +2385,7 @@ ${cols.join("\n")} paramString = "\t\t\tparams ${doc._rest._request._params.refClass.simpleName}.class" } else { List cols = doc._rest._request._params._cloumns.collect { - String values = it._values != null && !it._values.isEmpty() ? "(${it._values.collect { "\"$it\"" }.join(",")})" : null + String values = it._values != null && !it._values.isEmpty() ? "values (${it._values.collect { "\"$it\"" }.join(",")})" : null if (values == null) { return """\t\t\t\tcolumn { @@ -2461,7 +2406,7 @@ ${cols.join("\n")} \t\t\t\t\ttype "${it._type}" \t\t\t\t\toptional ${it._optional} \t\t\t\t\tsince "${it._since}" -\t\t\t\t\tvalues ${values} +\t\t\t\t\t${values} \t\t\t\t}""" } } @@ -2477,29 +2422,29 @@ ${cols.join("\n")} ${imports()} doc { -\ttitle "${doc._title}" + title "${doc._title}" -\tcategory "${doc._category}" + category "${doc._category}" -\tdesc \"\"\"${doc._desc}\"\"\" + desc \"\"\"${doc._desc}\"\"\" -\trest { -\t\trequest { + rest { + request { ${doc._rest._request._urls.collect { "\t\t\t" + it.toString() }.join("\n")} ${doc._rest._request._headers.collect { "\t\t\t" + it.toString() }.join("\n")} -\t\t\tclz ${doc._rest._request._clz.simpleName}.class - -\t\t\tdesc \"\"\"${doc._rest._request._desc}\"\"\" + clz ${doc._rest._request._clz.simpleName}.class + desc \"\"\"${doc._rest._request._desc}\"\"\" + ${paramString} -\t\t} + } -\t\tresponse { -\t\t\tclz ${doc._rest._response._clz.simpleName}.class -\t\t} -\t} + response { + clz ${doc._rest._response._clz.simpleName}.class + } + } }""" } @@ -2519,29 +2464,29 @@ ${paramString} ${imports()} doc { -\ttitle "${title}" + title "${title}" -\tcategory "${category}" + category "${category}" -\tdesc \"\"\"在这里填写API描述\"\"\" + desc \"\"\"在这里填写API描述\"\"\" -\trest { -\t\trequest { + rest { + request { ${urls()} ${headers()} -\t\t\tclz ${apiClass.simpleName}.class - -\t\t\tdesc \"\"\"\"\"\" + clz ${apiClass.simpleName}.class + desc \"\"\"\"\"\" + ${paramString} -\t\t} + } -\t\tresponse { -\t\t\tclz ${at.responseClass().simpleName}.class -\t\t} -\t} + response { + clz ${at.responseClass().simpleName}.class + } + } }""" } @@ -2865,23 +2810,13 @@ ${additionalRemark} } def scanJavaSourceFiles() { - ShellResult res = ShellUtils.runAndReturn("find ${rootPath} -name '*.java' -not -path '${rootPath}/sdk/*'") + ShellResult res = ShellUtils.runAndReturn("find ${rootPath} -name '*.java' -not -path '${rootPath}/sdk/*' -not -path '${rootPath}/plugin/expon/*'") List paths = res.stdout.split("\n") paths = paths.findAll { !(it - "\n" - "\r" - "\t").trim().isEmpty()} paths.each { def f = new File(it) - def key = f.name - ".java" - if (sourceFiles[key] == null) { - sourceFiles[key] = f - return - } - - // Two class names have the same name - // 1. Exclude classes containing '/plugin/expon/' paths - if (sourceFiles[key].absolutePath.contains("/plugin/expon/")) { - sourceFiles[key] = f - } + sourceFiles[f.name - ".java"] = f } } diff --git "a/rest/src/main/resources/scripts/SDK\347\224\237\346\210\220\345\231\250\345\274\200\345\217\221\346\214\207\345\215\227.md" "b/rest/src/main/resources/scripts/SDK\347\224\237\346\210\220\345\231\250\345\274\200\345\217\221\346\214\207\345\215\227.md" new file mode 100644 index 00000000000..75ac885af98 --- /dev/null +++ "b/rest/src/main/resources/scripts/SDK\347\224\237\346\210\220\345\231\250\345\274\200\345\217\221\346\214\207\345\215\227.md" @@ -0,0 +1,936 @@ +# ZStack SDK 生成器开发指南 + +> 本文档总结了 Go SDK 生成器的实现逻辑和经验,用于指导其他语言 SDK 生成器的开发。 + +--- + +## 1. 整体架构 + +### 1.1 核心组件 + +| 组件 | 文件 | 职责 | +|---------------|------------------------|-------------------------------------------| +| API 模板生成器 | `GoApiTemplate.groovy` | 生成 client actions、param 文件、response views | +| Inventory 生成器 | `GoInventory.groovy` | 生成 view 结构体、基础文件、client.go | +| 入口调用 | `RestServer.java` | 扫描 API 类并调用生成器 | + +### 1.2 生成的文件结构 + +``` +sdk/ +├── pkg/ +│ ├── client/ # API 操作方法 +│ │ ├── client.go # 客户端基类 +│ │ ├── {resource}_actions.go # 各资源的 CRUD 方法 +│ │ └── ... +│ ├── param/ # 请求参数 +│ │ ├── base_params.go # 基础参数结构 +│ │ ├── base_param_types.go # 嵌套类型定义 +│ │ └── {resource}_params.go # 各资源的参数 +│ ├── view/ # 响应视图 +│ │ ├── base_views.go # 基础视图结构 +│ │ ├── {resource}_views.go # @Inventory 类生成的视图 +│ │ ├── {resource}_additional_views.go # 被引用但无 @Inventory 的类 +│ │ └── {resource}_response_views.go # API 响应类型 +│ └── errors/ +│ └── errors.go # 错误定义 +└── go.mod +``` + +--- + +## 2. 生成流程 + +### 2.1 整体流程 (RestServer.java) + +``` +1. 扫描所有带 @RestRequest 注解的 API Message 类 +2. 创建 GoInventory 实例(单例,共享状态) +3. 对每个 API 类: + a. 创建 GoApiTemplate 实例 + b. 调用 generate() 生成 param、action、response view 文件 +4. 调用 GoInventory.generate() 生成: + a. @Inventory 类的 view 文件 + b. 被引用的 additional view 文件 + c. param 嵌套类型文件 + d. 基础文件(client.go, base_params.go, errors.go) +5. 写入所有文件到输出目录 +``` + +### 2.2 GoApiTemplate 生成流程 + +```groovy +List generate() { + // 1. 解析 API 类信息 + String clzName = parseApiName() // APICreateVmInstanceMsg -> CreateVmInstance + String actionType = parseActionType() // Create, Query, Update, Delete, ... + String httpMethod = getHttpMethod() // GET, POST, PUT, DELETE + + // 2. 确定视图类型 + if (isQueryMessage) { + viewClass = findInventoryClassFromResponse() // 从 inventories 字段获取 + } else { + viewClass = responseClass // 直接使用 responseClass + } + + // 3. 生成文件 + if (!isQueryMessage) { + generateParamFile() // {action}_params.go + } + generateActionFile() // {action}_actions.go + generateResponseViewFile() // {action}_response_views.go +} +``` + +### 2.3 GoInventory 生成流程 + +```groovy +List generate() { + // 1. 生成 @Inventory 类的 view 文件 + generateViewFiles() + + // 2. 生成被引用但无 @Inventory 的类 + generateAdditionalViewFiles() + + // 3. 生成 param 包的嵌套类型 + generateParamNestedTypesFile() + + // 4. 生成基础文件 + generateBaseViewFile() + generateBaseParamFile() + generateClientFile() + generateErrorsFile() +} +``` + +--- + +## 3. 类型映射规则 + +### 3.1 Java → Go 基本类型映射 + +**注意**: 对于可选字段(`@APIParam(required = false)`),基本类型会生成为指针类型(如 `*string`, `*int64`),以便区分未设置(nil)和零值。 + +| Java 类型 | Go 类型(必填) | Go 类型(可选) | +|-------------------------------|-------------------------------------------|-------------------------------------------| +| `String`, `char`, `Character` | `string` | `*string` | +| `int`, `Integer` | `int` | `*int` | +| `long`, `Long` | `int64` | `*int64` | +| `short`, `Short` | `int16` | `*int16` | +| `byte`, `Byte` | `int8` | `*int8` | +| `float`, `Float` | `float32` | `*float32` | +| `double`, `Double` | `float64` | `*float64` | +| `boolean`, `Boolean` | `bool` | `*bool` | +| `Date`, `Timestamp` | `time.Time` (param) / `ZStackTime` (view) | `*time.Time` (param) / `*ZStackTime` (view) | +| `Enum` | `string` | `*string` | +| `byte[]` | `[]int8` (Java byte 是有符号 -128~127) | `[]int8` | +| `List` | `[]T` | `[]T` | +| `Map` | `map[string]V` | `map[string]V` | +| 其他复杂类型 | 生成对应的 struct | - | +| 无法识别的类型 | `interface{}` | `interface{}` | + +### 3.2 上下文感知的类型映射 + +**重要**: `mapJavaTypeToGoType()` 需要根据上下文返回不同类型: + +- **param 包**(请求参数): `Date/Timestamp` → `time.Time` +- **view 包**(响应解析): `Date/Timestamp` → `ZStackTime`(自定义类型,支持 ZStack 特殊格式) + +```groovy +private String mapJavaTypeToGoType(Class javaType, boolean forView) { + switch (javaType) { + case Date.class: + case java.sql.Timestamp.class: + return forView ? "ZStackTime" : "time.Time" + // ... + } +} +``` + +**ZStackTime 实现**(仅在 view 包): + +```go +type ZStackTime struct { time.Time } + +func (t *ZStackTime) UnmarshalJSON(data []byte) error { + // 支持多种格式:"Jan 2, 2006 3:04:05 PM", "Jan 2, 2006 15:04:05", RFC3339 +} +``` + +### 3.3 类型处理决策树 + +``` +处理 Java 类型 fieldType: +│ +├─ 是基本类型? → 映射到 Go 基本类型(注意 forView 参数) +│ └─ 是可选字段 (@APIParam(required=false))? → 添加 * 指针前缀 +│ +├─ 是数组类型 (isArray)? → `[]` + 递归处理元素类型(slice本身可nil,不额外加*) +│ +├─ 集合类型 (Collection)? → `[]` + 处理泛型参数(slice本身可nil) +│ +├─ 是 Map 类型? → `map[string]` + 处理 value 泛型(map本身可nil) +│ +├─ 是枚举? → `string` +│ └─ 是可选字段? → `*string` +│ +├─ 有 @Inventory 注解? → 生成对应 View struct(struct可nil,不额外加*) +│ +├─ 是可生成的复杂类 (isGeneratableClass)? +│ ├─ 在 view 包 → 添加到 additionalClasses +│ └─ 在 param 包 → 添加到 paramNestedTypes +│ +└─ 其他 → `interface{}`(已支持nil) +``` + +### 3.4 数组类型特殊处理 + +**byte[] 映射为 []int8 而非 []byte**: + +```groovy +if (javaType.isArray()) { + Class componentType = javaType.getComponentType() + if (componentType == byte.class || componentType == Byte.class) { + // Java byte 是有符号类型 (-128 到 127) + // Go 的 byte 是 uint8 (0-255) + // Go 的 int8 是有符号 (-128 到 127) + return "[]int8" // ⚠️ 不是 []byte + } +} +``` + +**实际案例**: `ipInBinary` 字段存储负数值(如 -84),如果用 `[]byte` (即 `[]uint8`) 会导致 JSON 解析错误。 + +### 3.5 可生成类的判断 (isGeneratableClass) + +```groovy +boolean isGeneratableClass(Class clz) { + if (clz == null) return false + if (clz.isPrimitive()) return false // 排除基本类型 + if (clz.isEnum()) return false // 排除枚举 + if (clz.isInterface()) return false // 排除接口 + if (clz.isArray()) return false // 排除数组类型 + if (clz.name.startsWith("java.")) return false // 排除 Java 标准库 + if (clz.name.startsWith("javax.")) return false // 排除 javax + if (clz.name.startsWith("[")) return false // 排除数组内部表示 + return true +} +``` + +--- + +## 4. 关键问题和解决方案 + +### 4.1 递归类型(自引用) + +**问题**:类似 `ErrorCode` 包含 `cause: ErrorCode` 字段,Go 会报 "invalid recursive type" + +**解决**:检测自引用并使用指针类型 + +```groovy +// 追踪当前正在生成的类 +private Class currentGeneratingClass = null + +String generateViewStruct(Class clazz, String structName) { + Class previousClass = currentGeneratingClass + currentGeneratingClass = clazz + try { + // 生成字段... + } finally { + currentGeneratingClass = previousClass + } +} + +String generateFieldType(Field field, Type type) { + // 检测自引用 + boolean isSelfReference = (currentGeneratingClass != null && fieldType == currentGeneratingClass) + String pointerPrefix = isSelfReference ? "*" : "" + return pointerPrefix + getViewStructName(fieldType) +} +``` + +**生成结果**: + +```go +type ErrorCodeView struct { + Code string `json:"code"` + Cause *ErrorCodeView `json:"cause,omitempty"` // 使用指针 +} +``` + +### 4.2 类型重复生成 + +**问题**:同一个类可能同时是 `@Inventory` 类又被其他类引用 + +**解决**:使用 `generatedViewStructs` 集合追踪已生成的类型 + +```groovy +Set generatedViewStructs = new HashSet<>() + +void generateStruct(Class clz) { + String structName = getViewStructName(clz) + if (!generatedViewStructs.contains(structName)) { + generatedViewStructs.add(structName) + // 实际生成... + } +} +``` + +### 4.3 依赖发现(嵌套类型) + +**问题**:生成 struct A 时发现引用了 struct B,B 又引用 C...需要递归发现所有依赖 + +**解决**:使用迭代方式(非递归)处理新发现的类型 + +```groovy +def classesToProcess = new ArrayList(initialClasses) +int index = 0 + +while (index < classesToProcess.size()) { + Class clz = classesToProcess.get(index) + + // 生成 struct(会发现新的依赖类型,添加到 additionalClasses) + generateViewStruct(clz, structName) + + // 检查是否有新发现的类型 + additionalClasses.each { Class newClz -> + if (!classesToProcess.contains(newClz)) { + classesToProcess.add(newClz) // 添加到处理队列 + } + } + index++ +} +``` + +### 4.4 发现阶段与生成阶段分离 + +**问题**:发现阶段调用 `generateViewStruct` 可能污染状态,导致实际生成阶段跳过 + +**解决**:使用单独地集合追踪发现阶段处理的类 + +```groovy +// 发现阶段:只收集类,不标记为已生成 +def discoveredClasses = new HashSet() +while (index < classesToProcess.size()) { + Class clz = classesToProcess.get(index) + if (!discoveredClasses.contains(clz) && !generatedViewStructs.contains(structName)) { + discoveredClasses.add(clz) + generateViewStruct(clz, structName) // 触发依赖发现 + // ... + } + index++ +} + +// 生成阶段:实际写入文件 +discoveredClasses.each { Class clz -> + String structName = getViewStructName(clz) + if (!generatedViewStructs.contains(structName)) { + generatedViewStructs.add(structName) + content.append(generateViewStruct(clz, structName)) + } +} +``` + +### 4.5 HTTP 方法与导入处理 + +**问题**:DELETE 方法不返回 view 类型,但仍导入 view 包会报 "imported and not used" + +**解决**:根据 HTTP 方法和 action 类型决定导入 + +```groovy +// 判断是否为删除类操作 +boolean isDeleteAction = ["Delete", "Destroy", "Remove", "Expunge", "Detach", "Cleanup"] + .contains(actionType) || httpMethod == "DELETE" + +// 生成导入 +content.append("import (\n") +content.append("\t\"pkg/param\"\n") +if (!isDeleteAction) { + content.append("\t\"pkg/view\"\n") // 只有非删除操作才导入 view +} +content.append(")\n") +``` + +### 4.6 可选字段指针类型处理 + +**问题**:Go中如何区分未设置字段和零值(如空字符串""、0、false)? + +**解决**:对可选字段使用指针类型,nil表示未设置,非nil表示已设置 + +```groovy +/** + * 判断字段是否为可选字段 + */ +private boolean isOptionalField(Field field, Map apiParamMap) { + // 1. uuid 和 name 始终必填 + if (field.name in ["uuid", "name"]) { + return false + } + + // 2. 检查 @APIParam(required = false) + if (field.isAnnotationPresent(APIParam.class)) { + APIParam param = apiParamMap.containsKey(field.name) ? + apiParamMap.get(field.name) : field.getAnnotation(APIParam.class) + if (!param.required()) { + return true + } + } + + // 3. 没有APIParam注解的字段默认可选 + if (!field.isAnnotationPresent(APIParam.class)) { + return true + } + + return false +} + +/** + * 生成字段类型(支持可选字段指针) + */ +private String generateParamFieldType(Field field, Type type, + Map apiParamMap, + boolean isOptional) { + String baseType = mapJavaTypeToGoType(fieldType) + + // 基本类型集合 + def basicTypes = ["string", "int", "int64", "int32", + "float64", "float32", "bool"] as Set + + // 对可选的基本类型使用指针 + if (isOptional && !baseType.startsWith("[") && + !baseType.startsWith("map[") && + !baseType.equals("interface{}") && + basicTypes.contains(baseType)) { + return "*" + baseType + } + + return baseType +} +``` + +**生成结果**: + +```go +type UpdateVmInstanceDetailParam struct { + UUID string `json:"uuid" validate:"required"` // 必填 + Name *string `json:"name,omitempty"` // 可选,使用指针 + Description *string `json:"description,omitempty"` // 可选,使用指针 + CPUNum *int `json:"cpuNum,omitempty"` // 可选,使用指针 +} +``` + +**使用示例**: + +```go +// 只更新 name,不更新 description +name := "new-name" +params := UpdateVmInstanceDetailParam{ + UUID: "vm-uuid", + Name: &name, // 设置为新值 + // Description 为 nil,不会发送到服务器 +} +``` + +### 4.7 多参数路径处理(Query API) + +**问题**:部分Query API使用复合键而非单一uuid,如GlobalConfig使用`{category}/{name}` + +**解决**:自动检测URL占位符数量,多参数时使用`GetWithSpec`方法 + +```groovy +/** + * 提取URL中的占位符 + */ +private List extractUrlPlaceholders(String url) { + def placeholders = [] + def matcher = (url =~ /\{([^}]+)\}/) + while (matcher.find()) { + placeholders.add(matcher.group(1)) + } + return placeholders +} + +/** + * 为Query API生成Get方法 + */ +private String generateGetMethodForQuery(String apiPath, String viewStructName) { + def placeholders = extractUrlPlaceholders(apiPath) + String cleanPath = removePlaceholders(apiPath) + + // 多参数路径:使用 GetWithSpec + if (placeholders.size() >= 2) { + String params = placeholders.collect { "${it} string" }.join(", ") + String firstParam = placeholders[0] + def remainingPlaceholders = placeholders.drop(1) + String spec = buildSpecPath(remainingPlaceholders) + + return """ +func (cli *ZSClient) Get${resourceName}(ctx context.Context, ${params}) (*view.${viewStructName}, error) { + var resp view.${viewStructName} + err := cli.GetWithSpec(ctx, "${cleanPath}", ${firstParam}, ${spec}, "", nil, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} +""" + } + + // 单参数路径:标准 Get 方法 + return """ +func (cli *ZSClient) Get${resourceName}(ctx context.Context, uuid string) (*view.${viewStructName}, error) { + var resp view.${viewStructName} + if err := cli.Get(ctx, "${cleanPath}", uuid, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} +""" +} +``` + +**生成结果**: + +```go +// APIGetGlobalConfigOptionsMsg +func (cli *ZSClient) GetGlobalConfigOptions(ctx context.Context, category string, name string) (*view.GetGlobalConfigOptionsView, error) { + var resp view.GetGlobalConfigOptionsView + err := cli.GetWithSpec(ctx, "v1/global-configurations", category, fmt.Sprintf("%s", name), "", nil, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +// APIQueryVmInstanceMsg +func (cli *ZSClient) GetVmInstance(ctx context.Context, uuid string) (*view.VmInstanceInventoryView, error) { + var resp view.VmInstanceInventoryView + if err := cli.Get(ctx, "v1/vm-instances", uuid, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} +``` + +### 4.8 Groovy类型检查陷阱 + +**问题**:Groovy的`in`操作符对字符串列表检查不可靠 + +```groovy +// ❌ 错误:可能失败 +if (baseType in ["string", "int", "int64"]) { + return "*" + baseType +} +``` + +**解决**:使用Set的`contains()`方法 + +```groovy +// ✅ 正确:稳定可靠 +def basicTypes = ["string", "int", "int64", "int32", + "float64", "float32", "bool"] as Set +if (basicTypes.contains(baseType)) { + return "*" + baseType +} +``` + +### 4.9 方法签名一致性 + +**问题**:修改方法签名后,所有调用点必须同步更新,否则Groovy会报`MissingMethodException` + +**案例**:`generateParamFieldGeneric`从单个参数改为三个参数 + +```groovy +// 旧签名 +private String generateParamFieldGeneric(Field field) { ... } + +// 新签名(添加apiParamMap和isOptional) +private String generateParamFieldGeneric(Field field, + Map apiParamMap, + boolean isOptional) { ... } +``` + +**必须更新所有调用点**: + +```groovy +// generateParamStruct 中的调用 +boolean isOptional = isOptionalField(field, apiParamMap) +String fieldType = generateParamFieldGeneric(field, apiParamMap, isOptional) + +// generateParamNestedStruct 中的调用 +def apiParamMap = new HashMap() +if (clazz.isAnnotationPresent(OverriddenApiParams.class)) { + for (OverriddenApiParam oap : clazz.getAnnotation(OverriddenApiParams.class).value()) { + apiParamMap.put(oap.field(), oap.param()) + } +} +boolean isOptional = isOptionalField(field, apiParamMap) +String fieldType = generateParamFieldGeneric(field, apiParamMap, isOptional) +``` + +**错误示例**: + +``` +groovy.lang.MissingMethodException: No signature of method: + scripts.GoInventory.generateParamFieldGeneric() is applicable for + argument types: (java.lang.reflect.Field) +``` + +### 4.10 模块路径一致性 + +**问题**:生成的 import 路径必须与 `go.mod` 中的模块名一致 + +**配置**: + +```groovy +// 模块路径配置 +String modulePath = "dev.zstack.io/ye.zou/zsphere-go-sdk" + +// 生成导入时使用 +content.append("\t\"${modulePath}/pkg/param\"\n") +content.append("\t\"${modulePath}/pkg/view\"\n") +``` + +--- + +## 5. 命名规则 + +### 5.1 API 名称解析 + +```groovy +// APICreateVmInstanceMsg -> CreateVmInstance +String parseApiName(String className) { + return className + .replaceFirst("^API", "") + .replaceFirst("Msg\$", "") +} +``` + +### 5.2 View 结构体命名 + +```groovy +String getViewStructName(Class clz) { + String name = clz.simpleName + + if (name.endsWith("Inventory")) { + return name.replace("Inventory", "InventoryView") + // VmInstanceInventory -> VmInstanceInventoryView + } + if (name.endsWith("Reply")) { + return name.replace("Reply", "View").replaceAll("^API", "") + // APIQueryVmInstanceReply -> QueryVmInstanceView + } + return name + "View" + // ErrorCode -> ErrorCodeView +} +``` + +### 5.3 Param 结构体命名 + +```groovy +String getParamStructName(Class clz) { + String name = clz.simpleName + if (name.endsWith("Inventory")) { + return name.replace("Inventory", "") + "Param" + // AttributeInventory -> AttributeParam + } + return name + "Param" + // SecurityGroupRuleAO -> SecurityGroupRuleAOParam +} +``` + +### 5.4 文件名(snake_case) + +```groovy +String toSnakeCase(String name) { + return name.replaceAll('([a-z])([A-Z])', '$1_$2').toLowerCase() +} +// CreateVmInstance -> create_vm_instance +// IAM2Project -> iam2_project +``` + +--- + +## 6. 特殊情况处理 + +### 6.1 Query API 的 Inventory 类型查找 + +```groovy +Class findQueryInventoryClass() { + // 1. 从 Response 类的 inventories 字段获取泛型参数 + Field inventoriesField = responseClass.getDeclaredField("inventories") + Type genericType = inventoriesField.getGenericType() + if (genericType instanceof ParameterizedType) { + return (Class) ((ParameterizedType) genericType).getActualTypeArguments()[0] + } + + // 2. 回退:根据 API 名称匹配 Inventory 类 + String expectedName = resourceName + "Inventory" + for (Class inv : knownInventoryClasses) { + if (inv.simpleName == expectedName) return inv + } + + return null +} +``` + +### 6.2 RestResponse 注解处理 + +```groovy +if (clazz.isAnnotationPresent(RestResponse.class)) { + RestResponse at = clazz.getAnnotation(RestResponse.class) + + // allTo:将所有字段映射到指定字段 + if (at.allTo() != "") { + fieldMap = [at.allTo(): findField(clazz, at.allTo())] + } + + // fieldsTo:字段重命名 + // "newName=oldName" 或 "fieldName" + for (String fieldsTo : at.fieldsTo()) { + String[] split = fieldsTo.split("=") + String outputName = split[0] + String fieldName = split.length == 1 ? split[0] : split[1] + fieldMap[outputName] = findField(clazz, fieldName) + } +} +``` + +### 6.3 字段跳过规则 + +```groovy +// 跳过静态字段 +if (Modifier.isStatic(field.modifiers)) continue + +// 跳过 @APINoSee 注解的字段 +if (field.isAnnotationPresent(APINoSee.class)) continue + +// 跳过 View 中的 error 字段(由基类处理) +if (fieldName == 'error' && structName.endsWith('View')) continue + +// Param 中跳过基础字段 +def skipFields = ['systemTags', 'userTags', 'requestIp', 'session', + 'timeout', 'id', 'serviceId', 'creatingAccountUuid'] +if (skipFields.contains(fieldName)) continue +``` + +--- + +## 7. 最佳实践总结 + +### 7.1 架构设计 + +1. **单例共享生成器**:Inventory 生成器应为单例,跨 API 共享状态 +2. **两阶段处理**:先发现所有依赖类型,再统一生成 +3. **去重机制**:使用 Set 追踪已生成的类型名称 +4. **迭代而非递归**:处理嵌套依赖时使用队列,避免栈溢出 + +### 7.2 类型处理 + +1. **完整的类型映射表**:覆盖所有基本类型和常用类型 +2. **可选字段指针类型**:基本类型的可选字段使用`*`前缀(slice/map/interface{}除外) +3. **数组类型特殊处理**:检查 `isArray()` 和内部表示 `[`;注意`byte[]` → `[]int8`(非`[]byte`) +4. **递归类型使用指针**:避免 Go 编译器报错 +5. **类型检查使用Set.contains()**:避免Groovy `in`操作符的不可靠行为 +6. **兜底使用 interface{}**:无法识别的类型降级处理 + +### 7.3 方法签名管理 + +1. **方法签名修改时同步更新所有调用点**:Groovy方法重载严格匹配参数类型和数量 +2. **使用明确的参数传递**:避免依赖默认值或可选参数 +3. **重构时使用grep搜索**:确保找到所有调用点 + +### 7.4 导入管理 + +1. **按需导入**:根据实际使用决定是否导入包 +2. **避免未使用导入**:特别是 DELETE 操作不需要 view 包 +3. **时间包特殊处理**:使用 `var _ = time.Now` 避免未使用警告 + +### 7.5 调试支持 + +1. **详细日志**:记录生成过程中的关键决策 +2. **类型追踪**:记录发现和生成的类型数量 +3. **跳过记录**:记录因缺少信息而跳过的 API + +--- + +## 8. Go SDK 客户端使用指南 + +### 8.1 认证配置 + +#### 密码登陆(自动 SHA512 加密) + +```go +package main + +import ( + "context" + "fmt" + "time" + + "dev.zstack.io/ye.zou/zstack-go-sdk/pkg/client" +) + +func main() { + // 创建客户端配置 + config := &client.ZSConfig{ + Hostname: "172.20.1.164", + Port: 8080, + ContextPath: "/zstack", + AuthType: client.AuthTypeUsernamePassword, + Username: "admin", + Password: "password123", // ✅ 明文密码,客户端自动 SHA512 加密 + Timeout: 30 * time.Second, + } + + cli := client.NewZSClient(config) + ctx := context.Background() + // 开始使用 API + params := ¶m.QueryVmInstanceParam{} + vms, err := cli.QueryVmInstance(ctx, params) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("找到 %d 个 VM\n", len(vms)) +} +``` + +#### Access Key 认证(推荐) + +```go +config := &client.ZSConfig{ + Hostname: "172.20.1.164", + Port: 8080, + ContextPath: "/zstack", + AuthType: client.AuthTypeAccessKey, + AccessKeyId: "your-access-key-id", + AccessKeySecret: "your-access-key-secret", + Timeout: 30 * time.Second, +} +``` + +### 8.2 常见问题 + +**Q: API 返回 `status code 400 (Authorization: )`,说明什么?** + +A: 说明 Authorization 请求头为空,通常是以下原因: + +- ✗ 密码错误(客户端会自动 SHA512 加密,传入明文即可) +- ✗ Access Key 配置错误 +- ✗ 客户端配置中 AuthType 设置不正确 + +**Q: 密码需要手动加密吗?** + +A: **不需要**。`NewZSClient()` 内部会自动使用 SHA512 加密密码,直接传入明文即可: + +```go +config.Password = "password123" // ✅ 明文,自动加密 +// ❌ 不要:config.Password = hashPasswordSHA512("password123") +``` + +**Q: 如何验证加密后的密码?** + +```bash +echo -n "password123" | sha512sum +# 输出: b109f3bbbc244eb8... (128位十六进制) +``` + +--- + +## 9. 扩展到其他语言 + +### 9.1 需要调整的部分 + +| 方面 | Go | Java/Kotlin | TypeScript | Python | +|------|------------------|-----------------------|----------------------|------------------------| +| 类型系统 | 静态强类型 | 静态强类型 | 动态类型+类型注解 | 动态类型 | +| 空值处理 | 指针 `*T` | `Optional` | `T \| null` | `Optional[T]` | +| 集合类型 | `[]T`, `map[K]V` | `List`, `Map` | `T[]`, `Record` | `list[T]`, `dict[K,V]` | +| 时间类型 | `time.Time` | `Instant`, `Date` | `Date` | `datetime` | +| 枚举 | `string` + 常量 | `enum` | `enum` / 字符串联合 | `Enum` | +| 包/模块 | `package` | `package` | `export/import` | `import` | + +### 9.2 通用处理流程 + +``` +1. 扫描 @RestRequest API 类 +2. 解析 API 信息(名称、方法、路径、响应类型) +3. 生成请求参数类型 +4. 生成响应视图类型 +5. 生成 API 客户端方法 +6. 生成基础设施代码(客户端、错误处理、工具函数) +7. 处理依赖类型(递归发现和生成) +``` + +### 9.3 注意事项 + +- **保持命名一致性**:跨语言 SDK 的 API 名称应保持一致 +- **类型安全优先**:尽可能使用强类型而非 any/interface{} +- **文档生成**:从 Java 注释生成目标语言的文档注释 +- **版本同步**:SDK 版本应与 ZStack 版本对应 + +--- + +## 10. 附录:完整的字段处理伪代码 + +``` +function processField(field, apiParamMap): + type = field.type + + // 0. 判断字段是否可选 + isOptional = isOptionalField(field, apiParamMap) + + // 1. 基本类型映射 + if (type in PRIMITIVE_TYPE_MAP): + baseType = PRIMITIVE_TYPE_MAP[type] + // 可选的基本类型使用指针 + if (isOptional && type in BASIC_TYPES): + return "*" + baseType + return baseType + + // 2. 数组类型 + if (type.isArray()): + elementType = type.componentType + return "[]" + processType(elementType) + + // 3. 泛型集合 + if (type is ParameterizedType): + rawType = type.rawType + if (rawType == Collection): + return "[]" + processType(type.typeArguments[0]) + if (rawType == Map): + return "map[string]" + processType(type.typeArguments[1]) + + // 4. 枚举 + if (type.isEnum()): + return "string" + + // 5. 递归类型检查 + if (type == currentGeneratingClass): + return "*" + getStructName(type) // 使用指针 + + // 6. 可生成的复杂类 + if (isGeneratableClass(type)): + addToPendingGeneration(type) + return getStructName(type) + + // 7. 兜底 + return "interface{}" + +function isOptionalField(field, apiParamMap): + // uuid 和 name 始终必填 + if (field.name in ["uuid", "name"]): + return false + + // 检查 @APIParam(required = false) + if (field.hasAnnotation(APIParam)): + param = apiParamMap.get(field.name) or field.getAnnotation(APIParam) + if (param.required == false): + return true + return false + + // 没有注解的字段默认可选 + return true +``` diff --git a/rest/src/main/resources/scripts/SdkApiTemplate.groovy b/rest/src/main/resources/scripts/SdkApiTemplate.groovy index 4f45acc086a..dcaea05080d 100755 --- a/rest/src/main/resources/scripts/SdkApiTemplate.groovy +++ b/rest/src/main/resources/scripts/SdkApiTemplate.groovy @@ -1,403 +1,407 @@ package scripts -import org.apache.commons.lang.StringEscapeUtils import org.apache.commons.lang.StringUtils +import org.reflections.Reflections import org.zstack.core.Platform import org.zstack.header.exception.CloudRuntimeException -import org.zstack.header.identity.SuppressCredentialCheck -import org.zstack.header.message.* -import org.zstack.header.query.APIQueryMessage +import org.zstack.header.message.Message +import org.zstack.header.query.APIQueryReply import org.zstack.header.rest.APINoSee -import org.zstack.header.rest.RestRequest +import org.zstack.header.rest.NoSDK +import org.zstack.header.rest.RestResponse import org.zstack.header.rest.SDK -import org.zstack.header.rest.SDKPackage -import org.zstack.rest.sdk.SdkTemplate import org.zstack.rest.sdk.SdkFile -import org.zstack.utils.CollectionUtils +import org.zstack.rest.sdk.SdkTemplate import org.zstack.utils.FieldUtils import org.zstack.utils.Utils import org.zstack.utils.logging.CLogger import java.lang.reflect.Field -import java.util.stream.Collectors +import java.lang.reflect.Modifier /** - * Created by xing5 on 2016/12/9. + * Created by xing5 on 2016/12/11. */ -class SdkApiTemplate implements SdkTemplate { - CLogger logger = Utils.getLogger(SdkApiTemplate.class) +class SdkDataStructureGenerator implements SdkTemplate { + CLogger logger = Utils.getLogger(SdkDataStructureGenerator.class) - Class apiMessageClass - RestRequest requestAnnotation + Set responseClasses + Map sdkFileMap = [:] + Set laterResolvedClasses = [] - String resultClassName - boolean isQueryApi - String packageName + Map sourceClassMap = [:] - private static Map packageSDKAnnotations = [:] + Reflections reflections = Platform.reflections - static { - Platform.reflections.getTypesAnnotatedWith(SDKPackage.class).each { - packageSDKAnnotations[it.package] = it.getAnnotation(SDKPackage.class) - } + SdkDataStructureGenerator() { + Reflections reflections = Platform.getReflections() + responseClasses = reflections.getTypesAnnotatedWith(RestResponse.class) + laterResolvedClasses.addAll(reflections.getTypesAnnotatedWith(SDK.class) + .findAll() { !Message.class.isAssignableFrom(it) }) } - SdkApiTemplate(Class apiMessageClass) { - try { - packageName = getPackageName(apiMessageClass) + @Override + List generate() { + responseClasses.each { c -> + try { + generateResponseClass(c) + } catch (Throwable t) { + throw new CloudRuntimeException("failed to generate SDK for the class[${c.name}]", t) + } + } - this.apiMessageClass = apiMessageClass - this.requestAnnotation = apiMessageClass.getAnnotation(RestRequest.class) + resolveAllClasses() - String baseName = requestAnnotation.responseClass().simpleName - baseName = StringUtils.removeStart(baseName, "API") - baseName = StringUtils.removeEnd(baseName, "Event") - baseName = StringUtils.removeEnd(baseName, "Reply") + def ret = sdkFileMap.values() as List + ret.add(generateSourceDestClassMap()) - resultClassName = StringUtils.capitalize(baseName) - resultClassName = "${getPackageName(requestAnnotation.responseClass())}.${resultClassName}Result" + return ret + } + + def generateSourceDestClassMap() { + def srcToDst = [] + def dstToSrc = [] - isQueryApi = APIQueryMessage.class.isAssignableFrom(apiMessageClass) - } catch (Throwable t) { - throw new CloudRuntimeException(String.format("failed to make SDK for the class[%s]", apiMessageClass), t) + sourceClassMap.each { k, v -> + srcToDst.add("""\t\t\tput("${k}", "${v}");""") + dstToSrc.add("""\t\t\tput("${v}", "${k}");""") } - } - static String getFieldType(Field field) { - if (!field.type.name.startsWith("org.zstack")) { - return field.type.name + srcToDst.sort() + dstToSrc.sort() + + SdkFile f = new SdkFile() + f.fileName = "SourceClassMap.java" + f.content = """package org.zstack.sdk; + +import java.util.HashMap; + +public class SourceClassMap { + public final static HashMap srcToDstMapping = new HashMap() { + { +${srcToDst.join("\n")} } + }; - return "${getPackageName(field.type)}.${field.type.simpleName}" + public final static HashMap dstToSrcMapping = new HashMap() { + { +${dstToSrc.join("\n")} + } + }; +} +""" + return f } - static String getPackageName(Class clz) { - String packageName = "org.zstack.sdk" - - if (clz.getPackage() == null) { - return packageName + def resolveAllClasses() { + if (laterResolvedClasses.isEmpty()) { + return } - for (Map.Entry e : packageSDKAnnotations.entrySet()) { - String parentName = e.key.getName() - String pname = clz.getPackage().getName() + Set toResolve = [] + toResolve.addAll(laterResolvedClasses) - if (parentName == pname || pname.startsWith(parentName + ".")) { - if (e.value.packageName().isEmpty()) { - return packageName - } else { - String subPackageName = pname.replaceFirst(parentName, "") - return e.value.packageName() + subPackageName - } + toResolve.each { Class clz -> + try { + resolveClass(clz) + laterResolvedClasses.remove(clz) + } catch (Throwable t) { + throw new CloudRuntimeException("failed to generate SDK for the class[${clz.getName()}]", t) } } - return packageName + resolveAllClasses() } - def normalizeApiName() { - def name = StringUtils.removeStart(apiMessageClass.getSimpleName(), "API") - name = StringUtils.removeEnd(name, "Msg") - return StringUtils.capitalize(name) - } + def getTargetClassName(Class clz) { + SDK at = clz.getAnnotation(SDK.class) + if (at == null || at.sdkClassName().isEmpty()) { + return clz.getSimpleName() + } - def generateClassName() { - return String.format("%sAction", normalizeApiName()) + return at.sdkClassName() } - def generateFields() { - if (isQueryApi) { - return "" + def resolveClass(Class clz) { + if (clz.getName().contains("\$") && !Modifier.isStatic(clz.modifiers)) { + // ignore anonymous class + return } - def fields = FieldUtils.getAllFields(apiMessageClass) - - APIMessage msg = (APIMessage)apiMessageClass.newInstance() + if (clz.isAnnotationPresent(NoSDK.class)) { + return + } - def output = [] + if (sdkFileMap.containsKey(clz)) { + return + } - OverriddenApiParams oap = apiMessageClass.getAnnotation(OverriddenApiParams.class) - Map overriden = [:] - if (oap != null) { - for (OverriddenApiParam op : oap.value()) { - overriden.put(op.field(), op.param()) - } + if (isZStackClass(clz.superclass)) { + addToLaterResolvedClassesIfNeed(clz.superclass) } - for (Field f : fields) { - if (f.isAnnotationPresent(APINoSee.class)) { - continue - } + def output = [] + def imports = [] - boolean isDeprecated = f.getDeclaredAnnotation(Deprecated.class) != null - String annotationPrefix = isDeprecated ? " @Deprecated\n" : "" - - APIParam apiParam = overriden.containsKey(f.name) ? overriden[f.name] : f.getAnnotation(APIParam.class) - - def annotationFields = [] - if (apiParam != null) { - annotationFields.add(String.format("required = %s", apiParam.required())) - if (apiParam.validValues().length > 0) { - annotationFields.add(String.format("validValues = {%s}", { -> - def vv = [] - for (String v : apiParam.validValues()) { - vv.add("\"${v}\"") - } - return vv.join(",") - }())) - } else if (apiParam.validEnums().length > 0) { - annotationFields.add(String.format("validValues = {%s}", { -> - def vv = [] - def validValues = CollectionUtils.valuesForEnums(apiParam.validEnums()).collect(Collectors.toList()) - for (String v : validValues) { - vv.add("\"${v}\"") - } - return vv.join(",") - }())) - } - if (!apiParam.validRegexValues().isEmpty()) { - annotationFields.add(String.format("validRegexValues = \"%s\"", StringEscapeUtils.escapeJava(apiParam.validRegexValues()))) + if (!Enum.class.isAssignableFrom(clz)) { + for (Field f : clz.getDeclaredFields()) { + if (f.isAnnotationPresent(APINoSee.class)) { + continue } - if (apiParam.maxLength() != Integer.MIN_VALUE) { - annotationFields.add(String.format("maxLength = %s", apiParam.maxLength())) + + if (isZStackClass(f.type)) { + SDK at = f.type.getAnnotation(SDK.class) + String simpleName = at != null && !at.sdkClassName().isEmpty() ? at.sdkClassName() : f.type.getSimpleName() + imports.add("${SdkApiTemplate.getPackageName(f.type)}.${simpleName}") } - if (apiParam.minLength() != 0) { - annotationFields.add(String.format("minLength = %s", apiParam.minLength())) + + def text = makeFieldText(f.name, f) + if (text != null) { + output.add(text) } - annotationFields.add(String.format("nonempty = %s", apiParam.nonempty())) - annotationFields.add(String.format("nullElements = %s", apiParam.nullElements())) - annotationFields.add(String.format("emptyString = %s", apiParam.emptyString())) - if (apiParam.numberRange().length > 0) { - def nr = apiParam.numberRange() as List - def ns = [] - nr.forEach({ n -> return ns.add("${n}L")}) + } + } else { + for (Enum e : clz.getEnumConstants()) { + output.add("\t${e.name()},") + } + } - annotationFields.add(String.format("numberRange = {%s}", ns.join(","))) + String packageName = SdkApiTemplate.getPackageName(clz) - if (apiParam.numberRangeUnit().length > 0) { - def nru = apiParam.numberRangeUnit() as List + SdkFile file = new SdkFile() + file.subPath = packageName.replaceAll("\\.", "/") + file.fileName = "${getTargetClassName(clz)}.java" + if (!Enum.class.isAssignableFrom(clz)) { + file.content = """package ${packageName}; - annotationFields.add(String.format("numberRangeUnit = {\"%s\", \"%s\"}", nru.get(0), nru.get(1))) - } - } +${imports.collect { "import ${it};" }.join("\n")} - annotationFields.add(String.format("noTrim = %s", apiParam.noTrim())) - } else { - annotationFields.add(String.format("required = false")) - } +public class ${getTargetClassName(clz)} ${Object.class == clz.superclass ? "" : "extends " + SdkApiTemplate.getPackageName(clz.superclass) + "." + clz.superclass.simpleName} { - def fs = """${annotationPrefix}\ - @Param(${annotationFields.join(", ")}) - public ${getFieldType(f)} ${f.getName()}${{ -> - f.accessible = true - - Object val = f.get(msg) - if (val == null) { - return ";" - } - - if (val instanceof String) { - return " = \"${StringEscapeUtils.escapeJava(val.toString())}\";" - } else if (val instanceof Long) { - return " = ${val.toString()}L;" - } else { - return " = ${val.toString()};" - } - }()} +${output.join("\n")} +} """ - output.add(fs.toString()) - } - - if (!apiMessageClass.isAnnotationPresent(SuppressCredentialCheck.class)) { - output.add("""\ - @Param(required = false) - public String sessionId; -""") - output.add("""\ - @Param(required = false) - public String accessKeyId; -""") - output.add("""\ - @Param(required = false) - public String accessKeySecret; -""") } else { - output.add("""\ - @NonAPIParam - public boolean isSuppressCredentialCheck = true; -""") - } + file.content = """package ${packageName}; - output.add("""\ - @Param(required = false) - public String requestIp; -""") +public enum ${getTargetClassName(clz)} { +${output.join("\n")} +} +""" + } - if (!APISyncCallMessage.class.isAssignableFrom(apiMessageClass)) { - output.add("""\ - @NonAPIParam - public long timeout = -1; + sourceClassMap[clz.name] = "${packageName}.${getTargetClassName(clz)}" + sdkFileMap.put(clz, file) + } - @NonAPIParam - public long pollingInterval = -1; -""") + def isZStackClass(Class clz) { + if (clz.isArray() && clz.getComponentType() == byte.class) { + return false + } + if (clz.getName().startsWith("java.") || int.class == clz || long.class == clz + || short.class == clz || char.class == clz || boolean.class == clz || float.class == clz + || double.class == clz) { + return false + } else if (clz.getCanonicalName().startsWith("org.zstack")) { + return true + } else { + throw new CloudRuntimeException("${clz.getName()} is neither JRE class nor ZStack class") } - - return output.join("\n") } - def generateMethods(String path) { - def ms = [] - ms.add("""\ - private Result makeResult(ApiResult res) { - Result ret = new Result(); - if (res.error != null) { - ret.error = res.error; - return ret; + def addToLaterResolvedClassesIfNeed(Class clz) { + if (clz.isAnnotationPresent(NoSDK.class)) { + return } - - ${resultClassName} value = res.getResult(${resultClassName}.class); - ret.value = value == null ? new ${resultClassName}() : value; - return ret; - } -""") + if (!sdkFileMap.containsKey(clz)) { + laterResolvedClasses.add(clz) + } - ms.add("""\ - public Result call() { - ApiResult res = ZSClient.call(this); - return makeResult(res); - } -""") - - ms.add("""\ - public void call(final Completion completion) { - ZSClient.call(this, new InternalCompletion() { - @Override - public void complete(ApiResult res) { - completion.complete(makeResult(res)); + Platform.reflections.getSubTypesOf(clz).forEach({ i -> + if (!sdkFileMap.containsKey(i) && !i.isAnnotationPresent(NoSDK.class)) { + laterResolvedClasses.add(i) } - }); - } -""") - - ms.add("""\ - protected Map getParameterMap() { - return parameterMap; + }) } - protected Map getNonAPIParameterMap() { - return nonAPIParameterMap; - } -""") - - ms.add("""\ - protected RestInfo getRestInfo() { - RestInfo info = new RestInfo(); - info.httpMethod = "${requestAnnotation.method().name()}"; - info.path = "${path}"; - info.needSession = ${!apiMessageClass.isAnnotationPresent(SuppressCredentialCheck.class)}; - info.needPoll = ${!APISyncCallMessage.class.isAssignableFrom(apiMessageClass)}; - info.parameterName = "${requestAnnotation.isAction() ? StringUtils.uncapitalize(normalizeApiName()) : requestAnnotation.parameterName()}"; - return info; - } -""") + def generateResponseClass(Class responseClass) { + logger.debug("generating class: ${responseClass.name}") - return ms.join("\n") - } + RestResponse at = responseClass.getAnnotation(RestResponse.class) - def generateAction(String clzName, String path) { - def f = new SdkFile() - f.subPath = packageName.replaceAll("\\.", "/") - f.fileName = "${clzName}.java" - f.content = """package ${packageName}; + def fields = [:] -import java.util.HashMap; -import java.util.Map; -import org.zstack.sdk.*; + def addToFields = { String fname, Field f -> + if (isZStackClass(f.type)) { + addToLaterResolvedClassesIfNeed(f.type) + fields[fname] = f + } else { + fields[fname] = f + } + } -public class ${clzName} extends ${isQueryApi ? "QueryAction" : "AbstractAction"} { + if (!at.allTo().isEmpty()) { + Field f = getFieldRecursively(responseClass, at.allTo()) + addToFields(at.allTo(), f) + } else { + if (at.fieldsTo().length == 1 && at.fieldsTo()[0] == "all") { + for (Field f : responseClass.getDeclaredFields()) { + addToFields(f.name, f) + } + } else { + at.fieldsTo().each { s -> + def ss = s.split("=") + + def dst, src + if (ss.length == 2) { + dst = ss[0].trim() + src = ss[1].trim() + } else { + dst = src = ss[0] + } - private static final HashMap parameterMap = new HashMap<>(); + Field f = responseClass.getDeclaredField(src) + addToFields(dst, f) + } + } + } - private static final HashMap nonAPIParameterMap = new HashMap<>(); + // hack + if (APIQueryReply.class.isAssignableFrom(responseClass)) { + addToFields("total", responseClass.superclass.getDeclaredField("total")) + } - public static class Result { - public ErrorCode error; - public ${resultClassName} value; + def imports = [] + def output = [] + fields.each { String name, Field f -> + if (isZStackClass(f.type)) { + SDK sdkat = f.type.getAnnotation(SDK.class) + String simpleName = sdkat != null && !sdkat.sdkClassName().isEmpty() ? sdkat.sdkClassName() : f.type.getSimpleName() + imports.add("${SdkApiTemplate.getPackageName(f.type)}.${simpleName}") + } - public Result throwExceptionIfError() { - if (error != null) { - throw new ApiException( - String.format("error[code: %s, description: %s, details: %s]", error.code, error.description, error.details) - ); + def text = makeFieldText(name, f) + if (text != null) { + output.add(text) } - - return this; } - } -${generateFields()} + def className = responseClass.simpleName + className = StringUtils.removeStart(className, "API") + className = StringUtils.removeEnd(className, "Event") + className = StringUtils.removeEnd(className, "Reply") + className = StringUtils.capitalize(className) + className = "${className}Result" -${generateMethods(path)} -} -""".toString() + String packageName = SdkApiTemplate.getPackageName(responseClass) + SdkFile file = new SdkFile() + file.subPath = packageName.replaceAll("\\.", "/") + file.fileName = "${className}.java" + file.content = """package ${packageName}; - return f +${imports.collect { "import ${it};" }.join("\n")} + +public class ${className} { +${output.join("\n")} +} +""" + sdkFileMap[responseClass] = file } - def generateAction() { - SDK sdk = apiMessageClass.getAnnotation(SDK.class) - if (sdk != null && sdk.actionsMapping().length != 0) { - def ret = [] + static Field getFieldRecursively(Class clazz, String fieldName) { + try { + return clazz.getDeclaredField(fieldName) + } catch (NoSuchFieldException e) { + Class superClass = clazz.getSuperclass() - for (String ap : sdk.actionsMapping()) { - String[] aps = ap.split("=") - if (aps.length != 2) { - throw new CloudRuntimeException("Invalid actionMapping[${ap}] of the class[${apiMessageClass.name}]," + - "an action mapping must be in format of actionName=restfulPath") - } + if (superClass != null && superClass != Object.class) { + return getFieldRecursively(superClass, fieldName) + } else { + throw e + } + } + } - String aname = aps[0].trim() - String restPath = aps[1].trim() + def makeFieldText(String fname, Field field) { + // zstack type + if (isZStackClass(field.type) || Enum.class.isAssignableFrom(field.type)) { + addToLaterResolvedClassesIfNeed(field.type) - if (!requestAnnotation.optionalPaths().contains(restPath)) { - throw new CloudRuntimeException("Cannot find ${restPath} in the 'optionalPaths' of the @RestPath of " + - "the class[${apiMessageClass.name}]") - } + return """\ + public ${getTargetClassName(field.type)} ${fname}; + public void set${StringUtils.capitalize(fname)}(${getTargetClassName(field.type)} ${fname}) { + this.${fname} = ${fname}; + } + public ${getTargetClassName(field.type)} get${StringUtils.capitalize(fname)}() { + return this.${fname}; + } +""" + } - aname = StringUtils.capitalize(aname) + // skip static fields + if (Modifier.isStatic(field.modifiers)) { + return null + } - ret.add(generateAction("${aname}Action", restPath)) - } + // handle byte[] type + if (field.type == byte[].class) { + return """\ + public byte[] ${fname}; + public void set${StringUtils.capitalize(fname)}(byte[] ${fname}) { + this.${fname} = ${fname}; + } + public byte[] get${StringUtils.capitalize(fname)}() { + return this.${fname}; + } +""" + } - return ret - } else { - def requestPath = requestAnnotation.path() - if (requestPath == "null") { - throw new CloudRuntimeException("'path' is set to 'null' but no @SDK found on the class[${apiMessageClass.name}]") + // java type + if (Collection.class.isAssignableFrom(field.type)) { + Class genericType = FieldUtils.getGenericType(field) + if (genericType != null) { + if (isZStackClass(genericType)) { + addToLaterResolvedClassesIfNeed(genericType) + } } - if (requestPath.contains("{") || requestPath.contains("}")) { - def pattern = ~/\{([^}]*)}/ - requestPath = requestPath.replaceAll(pattern, "") - - if (requestPath.contains("{") || requestPath.contains("}")) { - throw new CloudRuntimeException("'path' value format is missing please check '{' and '}' in path on the class[${apiMessageClass.name}]") + return """\ + public ${field.type.name} ${fname}; + public void set${StringUtils.capitalize(fname)}(${field.type.name} ${fname}) { + this.${fname} = ${fname}; + } + public ${field.type.name} get${StringUtils.capitalize(fname)}() { + return this.${fname}; + } +""" + } else if (Map.class.isAssignableFrom(field.type)) { + Class genericType = FieldUtils.getGenericType(field) + if (genericType != null) { + if (isZStackClass(genericType)) { + addToLaterResolvedClassesIfNeed(genericType) } } - return [generateAction(generateClassName(), requestAnnotation.path())] - } + return """\ + public ${field.type.name} ${fname}; + public void set${StringUtils.capitalize(fname)}(${field.type.name} ${fname}) { + this.${fname} = ${fname}; } - - @Override - List generate() { - try { - return generateAction() - } catch (Exception e) { - logger.warn("failed to generate SDK for ${apiMessageClass.name}") - throw e + public ${field.type.name} get${StringUtils.capitalize(fname)}() { + return this.${fname}; + } +""" + } else { + return """\ + public ${field.type.name} ${fname}; + public void set${StringUtils.capitalize(fname)}(${field.type.name} ${fname}) { + this.${fname} = ${fname}; + } + public ${field.type.name} get${StringUtils.capitalize(fname)}() { + return this.${fname}; + } +""" } } } diff --git "a/rest/src/main/resources/scripts/ZStack SDK Go \345\274\200\345\217\221\350\247\204\350\214\203\344\270\216\346\240\207\345\207\206.md" "b/rest/src/main/resources/scripts/ZStack SDK Go \345\274\200\345\217\221\350\247\204\350\214\203\344\270\216\346\240\207\345\207\206.md" new file mode 100644 index 00000000000..3d15ba0f305 --- /dev/null +++ "b/rest/src/main/resources/scripts/ZStack SDK Go \345\274\200\345\217\221\350\247\204\350\214\203\344\270\216\346\240\207\345\207\206.md" @@ -0,0 +1,1088 @@ +# ZStack SDK Go 开发规范与标准 + +> **仓库地址**: ssh://git@dev.zstack.io:9022/zstackio/zsphere-go-sdk.git + +--- + +## Quick Start + +### 安装 + +```bash +go get github.com/terraform-zstack-modules/zsphere-sdk-go-v2 +``` + +### 初始化客户端 + +> **注意**: `DefaultZSConfig` 需要显式传入 context path (如 `"/zstack"`)。 + +```go +package main + +import ( + "fmt" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/client" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" +) + +func main() { + // 方式一:使用 AccessKey 认证(推荐) + cli := client.NewZSClient( + client.DefaultZSConfig("192.168.1.100", "/zstack"). + AccessKey("your-access-key-id", "your-access-key-secret"). + Debug(true), + ) + + // 方式二:使用账号密码认证(明文密码,客户端自动 SHA512 加密) + cli := client.NewZSClient( + client.DefaultZSConfig("192.168.1.100", "/zstack"). + Login("admin", "password"). // ✅ 传入明文密码即可 + Debug(true), + ) +} +``` + +### 查询虚拟机列表 + +```go +// 查询所有虚拟机 +vms, err := cli.QueryVmInstance(ctx, param.NewQueryParam()) +if err != nil { + panic(err) +} + +for _, vm := range vms { + fmt.Printf("VM: %s, UUID: %s, State: %s\n", vm.Name, vm.UUID, vm.State) +} +``` + +### 创建虚拟机 + +```go +createParam := param.CreateVmInstanceParam{ + BaseParam: param.BaseParam{}, + Params: param.CreateVmInstanceDetailParam{ + Name: "my-vm", + InstanceOfferingUUID: "offering-uuid", + ImageUUID: "image-uuid", + L3NetworkUuids: []string{"l3-network-uuid"}, + Description: "Created by Go SDK", + }, +} + +vm, err := cli.CreateVmInstance(ctx, createParam) +if err != nil { + panic(err) +} +fmt.Printf("Created VM: %s\n", vm.UUID) +``` + +### 获取单个资源 + +```go +// 根据 UUID 获取虚拟机详情 +vm, err := cli.GetVmInstance(ctx, "vm-uuid-here") +if err != nil { + panic(err) +} +fmt.Printf("VM Name: %s, CPU: %d, Memory: %d\n", vm.Name, vm.CPUNum, vm.MemorySize) +``` + +### 条件查询 + +```go +// 使用条件查询 +params := param.NewQueryParam(). + AddQ("state=Running"). + AddQ("name~=test"). // 模糊匹配 + Limit(10). + Start(0) + +vms, err := cli.QueryVmInstance(ctx, params) +``` + +### 删除资源 + +```go +// 删除虚拟机(宽松模式) +err := cli.DestroyVmInstance(ctx, "vm-uuid", param.Permissive) +if err != nil { + panic(err) +} +``` + +### 错误处理 + +```go +import "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/errors" + +vm, err := cli.GetVmInstance(ctx, "non-existent-uuid") +if err != nil { + if err == errors.ErrNotFound { + fmt.Println("VM not found") + } else { + fmt.Printf("Error: %v\n", err) + } +} +``` + +--- + +## 1. 项目结构 + +``` +zsphere-sdk-go-v2/ +├── pkg/ +│ ├── client/ # API 客户端和操作方法 +│ ├── param/ # 请求参数结构体 +│ ├── view/ # 响应视图结构体 +│ ├── errors/ # 错误定义和处理 +│ ├── util/ # 通用工具包 +│ │ ├── jsonutils/ # JSON 处理 +│ │ ├── httputils/ # HTTP 工具 +│ │ └── ... # 其他工具 +│ └── integration-test/ # 集成测试 +├── go.mod +├── go.sum +└── README.md +``` + +### 包职责说明 + +| 包名 | 职责 | 命名规范 | +|----------|------------|--------------------------| +| `client` | API 操作方法实现 | `{resource}_actions.go` | +| `param` | 请求参数定义 | `{resource}_params.go` | +| `view` | 响应数据结构 | `{resource}_views.go` | +| `errors` | 错误类型定义 | `errors.go`, `consts.go` | +| `util` | 通用工具函数 | 按功能划分子包 | + +--- + +## 2. 代码规范 + +### 2.1 文件头部 + +**所有 Go 文件必须包含版权声明:** + +```go +// Copyright (c) ZStack.io, Inc. + +package packagename +``` + +### 2.2 导入顺序 + +按以下顺序组织导入,组间用空行分隔: + +```go +import ( + // 1. 标准库 + "context" + "fmt" + "net/http" + + // 2. 第三方库 + "github.com/kataras/golog" + + // 3. 项目内部包 + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/errors" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view" +) +``` + +--- + +## 3. 命名规范 + +### 3.1 文件命名 + +| 类型 | 格式 | 示例 | +|---------|-------------------------|--------------------------| +| Actions | `{resource}_actions.go` | `vm_instance_actions.go` | +| Params | `{resource}_params.go` | `vm_instance_params.go` | +| Views | `{resource}_views.go` | `vm_instance_views.go` | +| Tests | `{resource}_test.go` | `vm_instance_test.go` | + +### 3.2 类型命名 + +```go +// 参数结构体:{Action}{Resource}Param +type CreateVmInstanceParam struct { ... } +type UpdateVmInstanceParam struct { ... } + +// 详细参数:{Action}{Resource}DetailParam +type CreateVmInstanceDetailParam struct { ... } + +// 视图结构体:{Resource}InventoryView 或 {Resource}View +type VmInstanceInventoryView struct { ... } +type VMConsoleAddressView struct { ... } + +// 类型别名 +type DeleteMode string +type InstanceType string +``` + +### 3.3 方法命名 + +```go +// CRUD 操作 +func (cli *ZSClient) Create{Resource}(ctx, params) (*View, error) +func (cli *ZSClient) Query{Resource}(ctx, params *QueryParam) ([]View, error) +func (cli *ZSClient) Get{Resource}(ctx, uuid) (*View, error) +func (cli *ZSClient) Update{Resource}(ctx, uuid, params) (*View, error) +func (cli *ZSClient) Destroy{Resource}(ctx, uuid, deleteMode) error +func (cli *ZSClient) Delete{Resource}(ctx, uuid, deleteMode) error + +// 特定操作 +func (cli *ZSClient) Start{Resource}(ctx, uuid, params) (*View, error) +func (cli *ZSClient) Stop{Resource}(ctx, uuid, params) (*View, error) +func (cli *ZSClient) Attach{A}To{B}(ctx, aUUID, bUUID) (*View, error) +func (cli *ZSClient) Detach{A}From{B}(ctx, aUUID, bUUID) (*View, error) +``` + +### 3.4 常量命名 + +```go +// 使用类型别名定义枚举 +type InstanceType string + +const ( + UserVm InstanceType = "UserVm" + ApplianceVm InstanceType = "ApplianceVm" +) + +// 错误常量 +const ( + ErrNotFound = Error("NotFoundError") + ErrDuplicateId = Error("DuplicateIdError") +) +``` + +--- + +## 4. 结构体设计模式 + +### 4.1 基础结构体嵌入 + +**View 结构体使用嵌入共享通用字段:** + +```go +// 基础信息视图 +type BaseInfoView struct { + UUID string `json:"uuid"` + Name string `json:"name"` + Description string `json:"description"` +} + +// 时间信息视图(使用 ZStackTime 支持特殊格式) +type BaseTimeView struct { + CreateDate ZStackTime `json:"createDate"` // ZStack 时间格式:"Jan 2, 2006 3:04:05 PM" + LastOpDate ZStackTime `json:"lastOpDate"` +} + +// ZStackTime 自定义类型(仅在 view 包) +type ZStackTime struct { + time.Time +} + +func (t *ZStackTime) UnmarshalJSON(data []byte) error { + // 支持 ZStack 的 "Oct 28, 2025 2:09:26 PM" 格式 + // 以及标准 RFC3339 格式 +} + +// 资源视图嵌入基础结构体 +type VmInstanceInventoryView struct { + BaseInfoView + BaseTimeView + + ZoneUUID string `json:"zoneUuid"` + ClusterUUID string `json:"clusterUuid"` + // ... 其他字段 +} +``` + +### 4.2 参数结构体嵌入 + +```go +// 基础参数 +type BaseParam struct { + SystemTags []string `json:"systemTags,omitempty"` + UserTags []string `json:"userTags,omitempty"` + RequestIp string `json:"requestIp,omitempty"` +} + +// 请求参数嵌入基础参数 +type CreateVmInstanceParam struct { + BaseParam + Params CreateVmInstanceDetailParam `json:"params"` +} + +// 详细参数:可选字段使用指针类型 +type CreateVmInstanceDetailParam struct { + Name string `json:"name" validate:"required"` // 必填 + InstanceOfferingUuid string `json:"instanceOfferingUuid" validate:"required"` // 必填 + ImageUuid string `json:"imageUuid" validate:"required"` // 必填 + L3NetworkUuids []string `json:"l3NetworkUuids" validate:"required"` // 必填 + Description *string `json:"description,omitempty"` // 可选,使用指针 + DefaultL3NetworkUuid *string `json:"defaultL3NetworkUuid,omitempty"` // 可选,使用指针 + Strategy *string `json:"strategy,omitempty"` // 可选,使用指针 +} +``` + +**使用示例**: + +```go +// 只设置必填字段,省略可选字段 +params := CreateVmInstanceDetailParam{ + Name: "my-vm", + InstanceOfferingUuid: "offering-uuid", + ImageUuid: "image-uuid", + L3NetworkUuids: []string{"network-uuid"}, + // Description 为 nil,不会发送到服务器 +} + +// 设置可选字段 +desc := "Test VM" +strategy := "InstantStart" +params := CreateVmInstanceDetailParam{ + Name: "my-vm", + InstanceOfferingUuid: "offering-uuid", + ImageUuid: "image-uuid", + L3NetworkUuids: []string{"network-uuid"}, + Description: &desc, // 使用指针 + Strategy: &strategy, // 使用指针 +} +``` + +``` + +### 4.3 Builder 模式 (方法链) + +```go +// 配置构建器 +func DefaultZSConfig(hostname, contextPath string) *ZSConfig { + return NewZSConfig(hostname, defaultZStackPort, contextPath) +} + +func (config *ZSConfig) AccessKey(id, secret string) *ZSConfig { + config.accessKeyId = id + config.accessKeySecret = secret + config.authType = AuthTypeAccessKey + return config +} + +func (config *ZSConfig) Debug(debug bool) *ZSConfig { + config.debug = debug + return config +} + +// 使用方式 +client := client.NewZSClient( + client.DefaultZSConfig("10.0.0.1", "/zstack"). + AccessKey("key-id", "key-secret"). + Debug(true), +) + +// 查询参数构建器 +params := param.NewQueryParam(). + AddQ("name=test"). + Limit(10). + Start(0) +``` + +--- + +## 5. JSON 标签规范 + +### 5.1 字段标签 + +```go +type ExampleStruct struct { + // 必填字段:无 omitempty + UUID string `json:"uuid"` + + // 可选字段:使用 omitempty + Description string `json:"description,omitempty"` + + // 指针类型用于区分零值和未设置 + RootDiskSize *int64 `json:"rootDiskSize"` + CpuNum *int `json:"cpuNum"` + + // 时间字段使用 ZStackTime(view 包)或 time.Time(param 包) + CreateDate ZStackTime `json:"createDate"` // view 包 + StartTime time.Time `json:"startTime"` // param 包 + + // byte 数组使用 []int8(Java byte 是有符号的) + IpInBinary []int8 `json:"ipInBinary,omitempty"` +} +``` + +### 5.2 字段注释 + +**所有导出字段必须有中文注释说明:** + +```go +type VmInstanceInventoryView struct { + UUID string `json:"uuid"` // 资源UUID,唯一标识 + ZoneUUID string `json:"zoneUuid"` // 区域UUID + ClusterUUID string `json:"clusterUuid"` // 集群UUID + MemorySize int64 `json:"memorySize"` // 内存大小(字节) + CPUNum int `json:"cpuNum"` // CPU数量 +} +``` + +--- + +## 6. 错误处理规范 + +### 6.1 错误定义 + +```go +// 使用自定义错误类型 +type Error string + +func (e Error) Error() string { + return string(e) +} + +// 预定义错误常量 +const ( + ErrNotFound = Error("NotFoundError") + ErrDuplicateId = Error("DuplicateIdError") + ErrParameter = Error("ParameterError") +) +``` + +### 6.2 错误包装 + +```go +import "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/errors" + +// 使用 Wrap 添加上下文 +if err != nil { + return errors.Wrap(err, "failed to create vm instance") +} + +// 使用 Wrapf 格式化上下文 +if err != nil { + return errors.Wrapf(err, "failed to query %s", resource) +} +``` + +### 6.3 API 方法错误处理 + +```go +func (cli *ZSClient) GetVmInstance(ctx context.Context, uuid string) (*view.VmInstanceInventoryView, error) { + var resp view.VmInstanceInventoryView + if err := cli.Get(ctx, "v1/vm-instances", uuid, nil, &resp); err != nil { + return nil, err // 直接返回错误,由调用方处理 + } + return &resp, nil +} +``` + +--- + +## 7. API 方法实现规范 + +### 7.1 标准方法模板 + +```go +// {Description} 方法描述 +func (cli *ZSClient) {MethodName}(ctx, params...) (*view.{ReturnType}, error) { + var resp view.{ReturnType} + if err := cli.{HttpMethod}(ctx, "v1/{resource}", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} +``` + +### 7.2 完整示例 + +```go +// CreateVmInstance 创建虚拟机实例 +func (cli *ZSClient) CreateVmInstance(ctx context.Context, params param.CreateVmInstanceParam) (*view.VmInstanceInventoryView, error) { + resp := view.VmInstanceInventoryView{} + if err := cli.Post(ctx, "v1/vm-instances", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// QueryVmInstance 查询虚拟机实例列表 +func (cli *ZSClient) QueryVmInstance(ctx context.Context, params *param.QueryParam) ([]view.VmInstanceInventoryView, error) { + var resp []view.VmInstanceInventoryView + return resp, cli.List(ctx, "v1/vm-instances", params, &resp) +} + +// DestroyVmInstance 删除虚拟机实例 +func (cli *ZSClient) DestroyVmInstance(ctx context.Context, uuid string, deleteMode param.DeleteMode) error { + return cli.Delete(ctx, "v1/vm-instances", uuid, string(deleteMode)) +} +``` + +--- + +## 8. 测试规范 + +### 8.1 测试文件位置 + +测试文件放在 `pkg/integration-test/` 目录下,命名格式:`{resource}_test.go` + +### 8.2 测试函数命名 + +```go +func Test{MethodName}(t *testing.T) { + // 测试实现 +} +``` + +### 8.3 测试模板 + +```go +// Copyright (c) ZStack.io, Inc. + +package test + +import ( + "context" + "testing" + + "github.com/kataras/golog" + + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/util/jsonutils" +) + +func TestQueryVmInstance(t *testing.T) { + ctx := context.Background() + data, err := accessKeyAuthCli.QueryVmInstance(ctx, param.NewQueryParam()) + if err != nil { + t.Errorf("TestQueryVmInstance: %v", err) + } + golog.Info(jsonutils.Marshal(data)) +} + +func TestGetVmInstance(t *testing.T) { + ctx := context.Background() + data, err := accountLoginCli.GetVmInstance(ctx, "uuid-here") + if err != nil { + t.Errorf("TestGetVmInstance: %v", err) + } + golog.Info(jsonutils.Marshal(data)) +} +``` + +--- + +## 9. 新增资源开发流程 + +当需要添加新的 ZStack 资源支持时,按以下步骤进行: + +### 步骤 1:定义视图结构体 + +在 `pkg/view/{resource}_views.go` 中定义: + +```go +// Copyright (c) ZStack.io, Inc. + +package view + +type {Resource}InventoryView struct { + BaseInfoView + BaseTimeView + + // 资源特定字段 + Field1 string `json:"field1"` // 字段说明 + Field2 int `json:"field2"` // 字段说明 +} +``` + +### 步骤 2:定义参数结构体 + +在 `pkg/param/{resource}_params.go` 中定义: + +```go +// Copyright (c) ZStack.io, Inc. + +package param + +type Create{Resource}Param struct { + BaseParam + Params Create{Resource}DetailParam `json:"params"` +} + +type Create{Resource}DetailParam struct { + Name string `json:"name"` // 名称 + Description string `json:"description"` // 描述 + // 其他参数 +} +``` + +### 步骤 3:实现 API 方法 + +在 `pkg/client/{resource}_actions.go` 中实现: + +```go +// Copyright (c) ZStack.io, Inc. + +package client + +import ( + "context" + + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view" +) + +// Create{Resource} 创建资源 +func (cli *ZSClient) Create{Resource}(ctx context.Context, params param.Create{Resource}Param) (*view.{Resource}InventoryView, error) { + resp := view.{Resource}InventoryView{} + if err := cli.Post(ctx, "v1/{resources}", params, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Query{Resource} 查询资源列表 +func (cli *ZSClient) Query{Resource}(ctx context.Context, params *param.QueryParam) ([]view.{Resource}InventoryView, error) { + var resp []view.{Resource}InventoryView + return resp, cli.List(ctx, "v1/{resources}", params, &resp) +} + +// Get{Resource} 获取单个资源 +func (cli *ZSClient) Get{Resource}(ctx context.Context, uuid string) (*view.{Resource}InventoryView, error) { + var resp view.{Resource}InventoryView + if err := cli.Get(ctx, "v1/{resources}", uuid, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} + +// Destroy{Resource} 删除资源 +func (cli *ZSClient) Destroy{Resource}(ctx context.Context, uuid string, deleteMode param.DeleteMode) error { + return cli.Delete(ctx, "v1/{resources}", uuid, string(deleteMode)) +} +``` + +### 步骤 4:编写测试 + +在 `pkg/integration-test/{resource}_test.go` 中编写集成测试。 + +--- + +## 11. SDK 生成器改进规则 (2026-01) + +### 11.1 时间类型处理优化 + +**规则**: 移除自定义 `ZStackTime` 类型,统一使用 Go 原生 `time.Time` + +**原因**: + +- 特殊时间格式解析应由独立的 utils 库处理,保持 SDK 代码简洁 +- 减少生成代码的复杂度和维护成本 +- 用户可根据需要选择时间处理工具 + +**修改**: + +```go +// 旧实现(移除) +type ZStackTime struct { time.Time } +func (t *ZStackTime) UnmarshalJSON(data []byte) error { ... } + +// 新实现 +type VmInstanceInventoryView struct { + BaseTimeView + // ... +} +``` + +**影响范围**: + +- `view` 包所有视图结构体 +- `param` 包的参数结构体 + +--- + +### 11.2 异步操作方法自动生成 + +**规则**: 为支持 LongJob 的资源自动生成 `Add{Resource}Async` 方法 + +**识别机制**: +通过 `@LongJobFor` 注解识别支持异步操作的 API: + +```java + +@LongJobFor(APIBackupStorageMigrateImageMsg.class) +public class BackupStorageMigrateImageLongJob { ... +} +``` + +**生成方法模板**: + +```go +// AddVmInstanceAsync 异步创建虚拟机 +// 返回 LongJob UUID 用于查询执行状态 +func (cli *ZSClient) AddVmInstanceAsync(ctx context.Context, params param.CreateVmInstanceParam) (string, error) { + var resp struct { + Location string `json:"location"` // LongJob UUID + } + if err := cli.Post(ctx, "v1/vm-instances", params, &resp); err != nil { + return "", err + } + // 从 Location header 提取 LongJob UUID + return extractLongJobUuid(resp.Location), nil +} +``` + +**实现要点**: + +1. 解析 `@LongJobFor` 注解获取目标 API 类 +2. 为目标 API 生成对应的 Async 方法 +3. 返回值为 LongJob UUID (string) +4. 用户可通过 `QueryLongJob(uuid)` 查询执行状态 + +--- + +### 11.3 资源查询方法增强 + +**规则**: 每个资源增加 `Get{Resource}(uuid)` 单参数查询方法 + +**目的**: + +- 简化最常见的单资源查询场景 +- 区分列表查询 `Query{Resource}(params)` 和单资源查询 `Get{Resource}(uuid)` + +**生成规则**: + +```go +// Get{Resource} 根据 UUID 获取单个资源 +func (cli *ZSClient) Get{Resource}(ctx context.Context, uuid string) (*view.{Resource}InventoryView, error) { + var resp view.{Resource}InventoryView + if err := cli.Get(ctx, "v1/{resources}", uuid, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} +``` + +**与 Query 的区别**: + +- `Get{Resource}(uuid)` - 获取单个资源,返回 `*View` +- `Query{Resource}(params)` - 查询资源列表,返回 `[]View` + +--- + +### 11.4 指针类型优化 + +**规则**: 可选字段使用指针类型,方便 nil 检查和区分零值 + +**适用场景**: + +```go +type VmInstanceInventoryView struct { + // 必填字段 - 不使用指针 + UUID string `json:"uuid"` + Name string `json:"name"` + + // 可选字段 - 使用指针 + Description *string `json:"description,omitempty"` + ZoneUUID *string `json:"zoneUuid,omitempty"` + + // 数值字段 - 使用指针区分 0 和未设置 + CPUNum *int `json:"cpuNum,omitempty"` + MemorySize *int64 `json:"memorySize,omitempty"` +} +``` + +**判断规则**: + +1. 有 `omitempty` 标签的字段使用指针 +2. 数值类型(int/int64/float64)如果可选,必须使用指针 +3. 字符串类型如果可选,使用指针 +4. 必填字段不使用指针 + +**使用示例**: + +```go +vm, _ := cli.GetVmInstance(ctx, "uuid") +if vm.Description != nil { + fmt.Println(*vm.Description) +} +``` + +--- + +### 11.5 基础结构体简化 + +**规则**: 基础结构体仅包含 `uuid` 和 `name` 字段 + +**新的 BaseInfoView 定义**: + +```go +// BaseInfoView 基础信息视图(仅包含通用标识字段) +type BaseInfoView struct { + UUID string `json:"uuid"` // 资源唯一标识 + Name string `json:"name,omitempty"` // 资源名称 +} +``` + +**继承规则**: + +- 资源有 `uuid` 和 `name` 字段 → 继承 `BaseInfoView` +- 资源只有 `uuid` 字段 → 不继承,直接定义 `UUID` 字段 +- 资源没有 `uuid` 字段 → 不继承 + +**移除字段**: + +- ❌ `Description` - 移入各资源自己的结构体 +- ❌ `CreateDate` / `LastOpDate` - 移除 `BaseTimeView`,各资源自己定义 + +**示例**: + +```go +// 继承 BaseInfoView +type VmInstanceInventoryView struct { + BaseInfoView + Description *string `json:"description,omitempty"` + CreateDate time.Time `json:"createDate,omitempty"` + // ... +} + +// 不继承(资源无 name 字段) +type SessionInventoryView struct { + UUID string `json:"uuid"` + CreateDate time.Time `json:"createDate,omitempty"` + // ... +} +``` + +--- + +### 11.6 多参数路径支持(Query API) + +**规则**: Query API 自动检测 URL 路径中的占位符,支持多参数路径 + +**背景**: + +- 大多数 Query API 使用单一 `{uuid}` 参数 +- 部分资源使用复合键,如 GlobalConfig 使用 `{category}/{name}` + +**检测逻辑**: + +```groovy +// 提取 URL 占位符 +def placeholders = extractUrlPlaceholders(apiPath) +// 例如:"/global-configurations/{category}/{name}" → ["category", "name"] + +if (placeholders.size() >= 2) { + // 生成多参数方法,使用 GetWithSpec +} else { + // 生成标准单参数方法 +} +``` + +**生成示例**: + +```go +// 单参数:APIQueryVmInstanceMsg (/vm-instances/{uuid}) +func (cli *ZSClient) GetVmInstance(ctx context.Context, uuid string) (*view.VmInstanceInventoryView, error) { + var resp view.VmInstanceInventoryView + if err := cli.Get(ctx, "v1/vm-instances", uuid, nil, &resp); err != nil { + return nil, err + } + return &resp, nil +} +``` + +**影响范围**: + +- Query API 的 Get 方法生成 +- 非-Query API 的 Get/Update/Delete 方法(如果路径有多个占位符) + +--- + +### 11.7 删除操作 URL 处理 + +**规则**: 删除操作的 URL 路径不包含占位符 `{uuid}`,由业务逻辑自动拼接 + +**旧实现**: + +```go +// ❌ 旧方式 +func (cli *ZSClient) Delete(ctx context.Context, path string, uuid string, deleteMode string) error { + url := strings.Replace(path, "{uuid}", uuid, 1) // 手动替换占位符 + // ... +} +``` + +**新实现**: + +```go +// ✅ 新方式 +func (cli *ZSClient) Delete(ctx context.Context, resource string, uuid string, deleteMode string) error { + // 自动拼接 URL: /v1/{resource}/{uuid} + url := fmt.Sprintf("%s/%s", resource, uuid) + if deleteMode != "" { + url += "?deleteMode=" + deleteMode + } + // ... +} +``` + +**生成的 action 方法**: + +```go +// 旧方式 +cli.Delete(ctx, "v1/vm-instances/{uuid}", uuid, deleteMode) + +// 新方式(更简洁) +cli.Delete(ctx, "v1/vm-instances", uuid, deleteMode) +``` + +**影响范围**: + +- 所有 DELETE 操作的 actions 方法 +- `client.go` 中的 `Delete()` 方法实现 + +--- + +### 11.8 客户端代码生成简化 + +**规则**: `client.go` 不再由生成器生成,使用固定模板文件 + +**原因**: + +- `client.go` 是基础设施代码,逻辑稳定 +- 避免每次生成都覆盖手动优化的实现 +- 简化生成器逻辑 + +**实现方式**: + +1. 创建 `client.go.template` 固定模板文件 +2. 生成器启动时直接复制模板到输出目录 +3. 移除 `GoInventory.generateClientFile()` 方法 + +**模板文件位置**: + +``` +rest/src/main/resources/scripts/templates/ +└── client.go.template # 固定的 client.go 实现 +``` + +**生成器调用**: + +```groovy +private SdkFile copyClientTemplate() { + def template = new File("templates/client.go.template") + def sdkFile = new SdkFile() + sdkFile.subPath = "/pkg/client/" + sdkFile.fileName = "client.go" + sdkFile.content = template.text + return sdkFile +} +``` + +--- + +### 11.8 可选字段指针类型支持(2026-01 新增) + +**规则**: 支持 `@APIParam(required = false)` 注解,自动将可选字段生成为指针类型 + +**目的**: + +- 区分未设置(nil)和零值(""、0、false) +- 提供更清晰的 API 语义 +- 避免误将零值作为有效输入发送到服务器 + +**判断逻辑**: + +```groovy +private boolean isOptionalField(Field field, Map apiParamMap) { + // 1. uuid 和 name 始终必填 + if (field.name in ["uuid", "name"]) { + return false + } + + // 2. 检查 @APIParam(required = false) + if (field.isAnnotationPresent(APIParam.class)) { + APIParam param = apiParamMap.containsKey(field.name) ? + apiParamMap.get(field.name) : field.getAnnotation(APIParam.class) + return !param.required() + } + + // 3. 没有APIParam注解的字段默认可选 + return true +} +``` + +**生成规则**: + +```groovy +// 基本类型集合 +def basicTypes = ["string", "int", "int64", "int32", "float64", "float32", "bool"] as Set + +// 可选的基本类型使用指针 +if (isOptional && basicTypes.contains(baseType)) { + return "*" + baseType // *string, *int64, *bool 等 +} + +// slice、map、interface{}、struct 本身已支持 nil,不额外添加指针 +return baseType +``` + +**生成示例**: + +```go +// UpdateVmInstanceDetailParam +type UpdateVmInstanceDetailParam struct { + UUID string `json:"uuid" validate:"required"` // 必填 + Name *string `json:"name,omitempty"` // 可选,使用指针 + Description *string `json:"description,omitempty"` // 可选,使用指针 + DefaultL3NetworkUuid *string `json:"defaultL3NetworkUuid,omitempty"` // 可选,使用指针 + CPUNum *int `json:"cpuNum,omitempty"` // 可选,使用指针 +} +``` + +**使用示例**: + +```go +// 只更新 name,不更新 description +name := "new-name" +params := UpdateVmInstanceDetailParam{ + UUID: "vm-uuid", + Name: &name, // 设置为新值 + // Description 为 nil,不会包含在 JSON 中 +} + +// 将 description 设置为空字符串(清空) +emptyDesc := "" +params := UpdateVmInstanceDetailParam{ + UUID: "vm-uuid", + Description: &emptyDesc, // 发送 "description": "" 到服务器 +} +``` + +**注意事项**: + +1. **方法签名一致性**: 修改 `generateParamFieldGeneric` 和 `generateParamFieldType` 后,必须更新所有调用点 +2. **Groovy 类型检查**: 使用 `Set.contains()` 而非 `in` 操作符,避免字符串匹配失败 +3. **嵌套类型处理**: `generateParamNestedStruct` 也需要构建 `apiParamMap` 并调用 `isOptionalField` + +--- + +## 12. Go 版本和依赖 + +- **Go 版本**: 1.22.0+ +- **主要依赖**: + - `github.com/kataras/golog` - 日志 + - `github.com/pkg/errors` - 错误处理 + - `github.com/fatih/color` - 终端颜色 + - `github.com/fatih/structs` - 结构体反射 + +--- + + diff --git a/rest/src/main/resources/scripts/templates/base_param_types.go.template b/rest/src/main/resources/scripts/templates/base_param_types.go.template new file mode 100644 index 00000000000..006b374c72e --- /dev/null +++ b/rest/src/main/resources/scripts/templates/base_param_types.go.template @@ -0,0 +1,45 @@ +// Copyright (c) ZStack.io, Inc. + +package param + +import "time" + +var _ = time.Now // avoid unused import + +type DeleteMode string + +const ( + DeleteModePermissive DeleteMode = "Permissive" + DeleteModeEnforcing DeleteMode = "Enforcing" +) + +type BaseParam struct { + SystemTags []string `json:"systemTags,omitempty"` // System tags + UserTags []string `json:"userTags,omitempty"` // User tags + RequestIp string `json:"requestIp,omitempty"` // Request IP +} + +type HqlParam struct { + OperationName string `json:"operationName"` // Request name + Query string `json:"query"` // Query statement + Variables Variables `json:"variables"` // Parameters for the statement +} + +type Variables struct { + Conditions []Condition `json:"conditions"` // Conditions + ExtraConditions []Condition `json:"extraConditions"` // Extra conditions + Input map[string]interface{} `json:"input"` // Input parameters + PageVar `json:",inline,omitempty"` + Type string `json:"type"` // Type +} + +type Condition struct { + Key string `json:"key"` // Key + Op string `json:"op"` // Operator + Value string `json:"value"` // Value +} + +type PageVar struct { + Start int `json:"start,omitempty"` // Start page + Limit int `json:"limit,omitempty"` // Limit per page +} diff --git a/rest/src/main/resources/scripts/templates/base_params.go.template b/rest/src/main/resources/scripts/templates/base_params.go.template new file mode 100644 index 00000000000..db722fa37ea --- /dev/null +++ b/rest/src/main/resources/scripts/templates/base_params.go.template @@ -0,0 +1,102 @@ +// Copyright (c) ZStack.io, Inc. + +package param + +import ( + "errors" + "fmt" + "net/url" + "reflect" + "strings" + + "github.com/fatih/structs" +) + +type QueryParam struct { + url.Values +} + +func NewQueryParam() QueryParam { + return QueryParam{ + Values: make(url.Values), + } +} + +// AddQ adds a query condition, similar to a MySQL database query. +// Omitting this field will return all records, with the number of returned records limited by the 'limit' field. +func (params *QueryParam) AddQ(q string) *QueryParam { + if params.Get("q") == "" { + params.Set("q", q) + } else { + params.Add("q", q) + } + return params +} + +// Limit sets the maximum number of records to return, similar to MySQL's 'limit'. Default value is 1000. +func (params *QueryParam) Limit(limit int) *QueryParam { + params.Set("limit", fmt.Sprintf("%d", limit)) + return params +} + +// Start sets the starting position for the query, similar to MySQL's 'offset'. Used with 'limit' for pagination. +func (params *QueryParam) Start(start int) *QueryParam { + params.Set("start", fmt.Sprintf("%d", start)) + return params +} + +// Count sets the query to return the count of records that match the query conditions, similar to MySQL's 'count()' function. +func (params *QueryParam) Count(count bool) *QueryParam { + params.Set("count", fmt.Sprintf("%t", count)) + return params +} + +// GroupBy groups the results by a specified field, similar to MySQL's 'group by' keyword. +func (params *QueryParam) GroupBy(groupBy string) *QueryParam { + params.Set("groupBy", groupBy) + return params +} + +// ReplyWithCount, when set to true, includes the total count of records that match the query in the response. +func (params *QueryParam) ReplyWithCount(replyWithCount bool) *QueryParam { + params.Set("replyWithCount", fmt.Sprintf("%t", replyWithCount)) + return params +} + +// FilterName sets a filter name, functionality is unknown from ZStack Java SDK (sdk-4.4.0.jar). +func (params *QueryParam) FilterName(filterName string) *QueryParam { + params.Set("filterName", filterName) + return params +} + +// Sort sorts the results by a specified field, similar to MySQL's 'sort by' keyword. +// Use '+' for ascending order and '-' for descending order, followed by the field name. +func (params *QueryParam) Sort(sort string) *QueryParam { + params.Set("sort", sort) + return params +} + +// Fields specifies the fields to return, similar to MySQL's 'select' fields functionality. +func (params *QueryParam) Fields(fields []string) *QueryParam { + params.Set("fields", strings.Join(fields, ",")) + return params +} + +// ConvertStruct2UrlValues converts a struct to url.Values. +func ConvertStruct2UrlValues(param interface{}) (url.Values, error) { + if reflect.Ptr != reflect.TypeOf(param).Kind() { + return nil, errors.New("model should be pointer kind") + } + result := url.Values{} + if param == nil || reflect.ValueOf(param).IsNil() { + return nil, errors.New("param is nil") + } + + s := structs.New(param) + s.TagName = "json" + mappedOpts := s.Map() + for k, v := range mappedOpts { + result.Set(k, fmt.Sprintf("%v", v)) + } + return result, nil +} diff --git a/rest/src/main/resources/scripts/templates/client.go.template b/rest/src/main/resources/scripts/templates/client.go.template new file mode 100644 index 00000000000..4418e89bd19 --- /dev/null +++ b/rest/src/main/resources/scripts/templates/client.go.template @@ -0,0 +1,232 @@ +// Copyright (c) ZStack.io, Inc. + +package client + +import ( + "context" + "crypto/sha512" + "fmt" + "net/http" + "net/url" + + "github.com/kataras/golog" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/errors" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/view" +) + +type ZSClient struct { + *ZSHttpClient +} + +func NewZSClient(config *ZSConfig) *ZSClient { + return &ZSClient{ + ZSHttpClient: NewZSHttpClient(config), + } +} + +func (cli *ZSClient) Login(ctx context.Context) (*view.SessionInventoryView, error) { + if cli.authType != AuthTypeAccountUser && cli.authType != AuthTypeAccount { + return nil, errors.ErrNotSupported + } + + var sessionView *view.SessionInventoryView + var err error + if cli.authType == AuthTypeAccountUser { + sessionView, err = cli.logInByAccountUser(ctx) + } else { + sessionView, err = cli.logInByAccount(ctx) + } + + if err != nil { + golog.Errorf("ZSClient.Login error:%v", err) + return nil, err + } + + cli.LoadSession(sessionView.Uuid) + return sessionView, nil +} + +func (cli *ZSClient) logInByAccountUser(ctx context.Context) (*view.SessionInventoryView, error) { + if cli.authType != AuthTypeAccountUser { + return nil, errors.ErrNotSupported + } + + if len(cli.accountName) == 0 || len(cli.accountUserName) == 0 || len(cli.password) == 0 { + return nil, errors.ErrParameter + } + + params := param.LogInByUserParam{ + LogInByUser: param.LogInByUserDetailParam{ + AccountName: cli.accountName, + UserName: cli.accountUserName, + Password: fmt.Sprintf("%x", sha512.Sum512([]byte(cli.password))), + }, + } + sessionView := view.SessionInventoryView{} + err := cli.Put(ctx, "v1/accounts/users/login", "", params, &sessionView) + if err != nil { + golog.Errorf("ZSClient.logInByAccountUser Account[%s] User[%s] error:%v", + cli.accountName, cli.accountUserName, err) + return nil, err + } + + return &sessionView, nil +} + +func (cli *ZSClient) logInByAccount(ctx context.Context) (*view.SessionInventoryView, error) { + if cli.authType != AuthTypeAccount { + return nil, errors.ErrNotSupported + } + + if len(cli.accountName) == 0 || len(cli.password) == 0 { + return nil, errors.ErrParameter + } + + params := param.LoginByAccountParam{ + LoginByAccount: param.LoginByAccountDetailParam{ + AccountName: cli.accountName, + Password: fmt.Sprintf("%x", sha512.Sum512([]byte(cli.password))), + }, + } + sessionView := view.SessionInventoryView{} + err := cli.Put(ctx, "v1/accounts/login", "", params, &sessionView) + if err != nil { + golog.Errorf("ZSClient.logInByAccount Account[%s] error:%v", cli.accountName, err) + return nil, err + } + + return &sessionView, nil +} + +func (cli *ZSClient) ValidateSession(ctx context.Context) (map[string]bool, error) { + if cli.authType != AuthTypeAccountUser && cli.authType != AuthTypeAccount { + return nil, errors.ErrNotSupported + } + + if len(cli.sessionId) == 0 { + return nil, errors.ErrNotSupported + } + + return cli.ValidateSessionId(ctx, cli.sessionId) +} + +func (cli *ZSClient) ValidateSessionId(ctx context.Context, sessionId string) (map[string]bool, error) { + validSession := make(map[string]bool) + err := cli.GetWithSpec(ctx, "v1/accounts/sessions", sessionId, "valid", "", nil, &validSession) + if err != nil { + golog.Errorf("ZSClient.ValidateSession sessionId[%s] error:%v", sessionId, err) + return nil, err + } + + golog.Debugf("ZSClient.ValidateSession sessionId[%s]:%v", sessionId, validSession) + return validSession, nil +} + +func (cli *ZSClient) Logout(ctx context.Context) error { + if cli.authType != AuthTypeAccountUser && cli.authType != AuthTypeAccount { + return errors.ErrNotSupported + } + + if len(cli.sessionId) == 0 { + return errors.ErrNotSupported + } + + err := cli.Delete(ctx, "v1/accounts/sessions", cli.sessionId, "") + if err != nil { + golog.Errorf("ZSClient.Logout sessionId[%s] error:%v", cli.sessionId, err) + return err + } + + cli.unloadSession() + return nil +} + +func (cli *ZSClient) WebLogin(ctx context.Context) (*view.WebUISessionView, error) { + if cli.authType != AuthTypeAccountUser && cli.authType != AuthTypeAccount { + return nil, errors.ErrNotSupported + } + + var operationName, username, loginType, query string + var input map[string]interface{} + if cli.authType == AuthTypeAccount { + operationName, username, loginType = "loginByAccount", cli.accountName, "iam1" + input = map[string]interface{}{ + "accountName": cli.accountName, + "password": fmt.Sprintf("%x", sha512.Sum512([]byte(cli.password))), + } + query = `mutation loginByAccount($input:LoginByAccountInput!) { + loginByAccount(input: $input) { + sessionId, + accountUuid, + userUuid, + currentIdentity + } + }` + } else { + operationName, username, loginType = "loginIAM2VirtualID", cli.accountUserName, "iam2" + input = map[string]interface{}{ + "name": cli.accountUserName, + "password": fmt.Sprintf("%x", sha512.Sum512([]byte(cli.password))), + } + query = `mutation loginIAM2VirtualID($input:LoginIAM2VirtualIDInput!) { + loginIAM2VirtualID(input: $input) { + sessionId, + accountUuid, + userUuid, + currentIdentity + } + }` + } + + result := new(view.WebUISessionView) + params := param.HqlParam{ + OperationName: operationName, + Query: query, + Variables: param.Variables{ + Input: input, + }, + } + respHeader, err := cli.hql(ctx, params, result, responseKeyData, operationName) + if err != nil { + return nil, err + } + result.UserName = username + result.LoginType = loginType + result.ZSVersion = respHeader.Get("Zs-Version") + return result, nil +} + +func (cli *ZSClient) hql(ctx context.Context, params param.HqlParam, retVal interface{}, unMarshalKeys ...string) (http.Header, error) { + urlStr := fmt.Sprintf("http://%s:%d/graphql", cli.hostname, WebZStackPort) + _, respHeader, resp, err := cli.httpPost(ctx, urlStr, jsonMarshal(params), false) + if err != nil { + return nil, err + } + + if retVal == nil { + return nil, nil + } + + return respHeader, resp.Unmarshal(retVal, unMarshalKeys...) +} + +func (cli *ZSClient) Zql(ctx context.Context, querySt string, retVal interface{}, unMarshalKeys ...string) (http.Header, error) { + encodedQuery := url.QueryEscape(querySt) + baseUrl := cli.getRequestURL("v1/zql") + urlStr := fmt.Sprintf("%s?zql=%s", baseUrl, encodedQuery) + _, respHeader, resp, err := cli.httpGet(ctx, urlStr, false) + if err != nil { + return nil, err + } + + if retVal == nil { + return nil, nil + } + + return respHeader, resp.Unmarshal(retVal, unMarshalKeys...) +} + +const ( + AuthTypeLogin AuthType = "login" +) diff --git a/rest/src/main/resources/scripts/templates/example_main.go b/rest/src/main/resources/scripts/templates/example_main.go new file mode 100644 index 00000000000..e9e5df35f55 --- /dev/null +++ b/rest/src/main/resources/scripts/templates/example_main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "fmt" + + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/client" + "github.com/terraform-zstack-modules/zsphere-sdk-go-v2/pkg/param" +) + +func main() { + // 创建客户端配置 + // 方式1: 使用完整配置 + // config := client.NewZSConfig("YOUR_ZSTACK_API_ENDPOINT", 8080, "/zstack"). + // LoginAccount("admin", "password"). + // Debug(true) + + // 方式2: 使用默认配置(推荐) + config := client.DefaultZSConfig("YOUR_ZSTACK_API_ENDPOINT"). + LoginAccount("admin", "password"). + Debug(true) + + // 初始化客户端 + cli := client.NewZSClient(config) + ctx := context.Background() + // 登录 + fmt.Println("正在登录...") + sessionView, err := cli.Login(ctx) + if err != nil { + fmt.Printf("登录失败: %v\n", err) + return + } + fmt.Printf("登录成功!Session UUID: %s\n", sessionView.Uuid) + + // 查询虚拟机列表 + fmt.Println("\n开始查询虚拟机...") + queryParams := param.NewQueryParam() + queryParams.Limit(10) + vms, err := cli.QueryVmInstance(ctx, &queryParams) + if err != nil { + fmt.Printf("查询失败: %v\n", err) + return + } + + fmt.Printf("查询成功!共找到 %d 台虚拟机\n", len(vms)) + for i, vm := range vms { + fmt.Printf("[%d] VM: %s, UUID: %s, State: %s\n", i+1, vm.Name, vm.Uuid, vm.State) + } + + // 获取单个虚拟机详情(如果有虚拟机的话) + if len(vms) > 0 { + fmt.Printf("\n获取第一台虚拟机详情...\n") + vmDetail, err := cli.GetVmInstance(ctx, vms[0].Uuid) + if err != nil { + fmt.Printf("获取虚拟机详情失败: %v\n", err) + } else { + fmt.Printf("虚拟机详情: Name=%s, UUID=%s, State=%s, CPUs=%d, Memory=%d\n", + vmDetail.Name, vmDetail.Uuid, vmDetail.State, vmDetail.CpuNum, vmDetail.MemorySize) + } + } + + // 查询镜像列表 + fmt.Println("\n查询镜像列表...") + imageParams := param.NewQueryParam() + imageParams.Limit(5) + images, err := cli.QueryImage(ctx, &imageParams) + if err != nil { + fmt.Printf("查询镜像失败: %v\n", err) + } else { + fmt.Printf("共找到 %d 个镜像\n", len(images)) + for i, img := range images { + fmt.Printf("[%d] Image: %s, UUID: %s, Format: %s\n", i+1, img.Name, img.Uuid, img.Format) + } + } + + // 验证会话是否有效 + fmt.Println("\n验证会话...") + valid, err := cli.ValidateSession(ctx) + if err != nil { + fmt.Printf("验证会话失败: %v\n", err) + } else { + fmt.Printf("会话有效性: %v\n", valid) + } + + // 登出 + fmt.Println("\n正在登出...") + if err := cli.Logout(ctx); err != nil { + fmt.Printf("登出失败: %v\n", err) + } else { + fmt.Println("登出成功!") + } +} diff --git a/rest/src/main/resources/scripts/templates/login_params.go.template b/rest/src/main/resources/scripts/templates/login_params.go.template new file mode 100644 index 00000000000..76f95e29305 --- /dev/null +++ b/rest/src/main/resources/scripts/templates/login_params.go.template @@ -0,0 +1,55 @@ +// Copyright (c) ZStack.io, Inc. + +package param + +type LoginByAccountParam struct { + BaseParam + LoginByAccount LoginByAccountDetailParam `json:"logInByAccount"` +} + +type LoginByAccountDetailParam struct { + AccountName string `json:"accountName"` // Account name + Password string `json:"password"` // Password + AccountType string `json:"accountType"` // Account type + CaptchaUuid string `json:"captchaUuid"` // Captcha UUID + VerifyCode string `json:"verifyCode"` // Verification code + ClientInfo map[string]interface{} `json:"clientInfo"` // Client information +} + +type LogInByUserParam struct { + BaseParam + LogInByUser LogInByUserDetailParam `json:"logInByUser"` +} + +type LogInByUserDetailParam struct { + AccountUuid string `json:"accountUuid"` // Account UUID + AccountName string `json:"accountName"` // Account name + UserName string `json:"userName"` // User name + Password string `json:"password"` // Password + ClientInfo map[string]interface{} `json:"clientInfo"` // Client information +} + +type LoginIAM2VirtualIDWithLdapParam struct { + BaseParam + LoginIAM2VirtualIDWithLdap LoginIAM2VirtualIDWithLdapDetailParam `json:"loginIAM2VirtualIDWithLdap"` +} + +type LoginIAM2VirtualIDWithLdapDetailParam struct { + VirtualIDUuid string `json:"virtualIDUuid"` // Virtual ID UUID + LdapUid string `json:"ldapUid"` // LDAP UID + Password string `json:"password"` // Password +} + +type LoginIAM2PlatformParam struct { + BaseParam + LoginIAM2Platform LoginIAM2PlatformDetailParam `json:"loginIAM2Platform"` +} + +type LoginIAM2PlatformDetailParam struct { + VirtualIDUuid string `json:"virtualIDUuid"` // Virtual ID UUID + Password string `json:"password"` // Password +} + +type ValidateSessionParam struct { + BaseParam +} diff --git a/rest/src/main/resources/scripts/templates/session_additional_views.go.template b/rest/src/main/resources/scripts/templates/session_additional_views.go.template new file mode 100644 index 00000000000..6a540ffdfed --- /dev/null +++ b/rest/src/main/resources/scripts/templates/session_additional_views.go.template @@ -0,0 +1,26 @@ +// Copyright (c) ZStack.io, Inc. + +package view + +import "time" + +// SessionInventoryView Session +type SessionInventoryView struct { + Uuid string `json:"uuid"` + AccountUuid string `json:"accountUuid,omitempty"` + UserUuid string `json:"userUuid,omitempty"` + UserType string `json:"userType,omitempty"` + ExpiredDate time.Time `json:"expiredDate,omitempty"` + CreateDate time.Time `json:"createDate,omitempty"` +} + +// WebUISessionView Web UI Session +type WebUISessionView struct { + SessionId string `json:"sessionId"` // Session ID + AccountUuid string `json:"accountUuid"` // Account UUID + UserUuid string `json:"userUuid"` // User UUID + UserName string `json:"username"` // Username + LoginType string `json:"loginType"` // Login type + CurrentIdentity string `json:"currentIdentity"` // Current identity + ZSVersion string `json:"zsVersion"` // ZStack Cloud version +}