Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ public class RubyNextgenClientCodegen extends AbstractRubyCodegen {
private Set<String> resourceSegments = Collections.emptySet();
private String apiBasePrefix = "";
private int emptyMethodNameCounter = 0;
private List<Map<String, Object>> rubyNamespaces = Collections.emptyList();
private final Set<String> namespaceOnlyApiTags = new HashSet<>();

// Accumulated across postProcessModels calls: file basename -> class name, for every
// autoloaded model. Consumed by postProcessSupportingFileData to emit Zeitwerk
Expand Down Expand Up @@ -531,12 +533,111 @@ public void preprocessOpenAPI(OpenAPI openAPI) {
this.apiBasePrefix = additionalProperties.containsKey("apiBasePath")
? stripSlashes((String) additionalProperties.get("apiBasePath"))
: RubyApiRouting.commonBasePrefix(paths);
this.rubyNamespaces = buildRubyNamespaces(openAPI);
this.namespaceOnlyApiTags.clear();
additionalProperties.put("rbNamespaces", rubyNamespaces);
}

private static String stripSlashes(String s) {
return s == null ? "" : s.replaceAll("^/+", "").replaceAll("/+$", "");
}

private List<Map<String, Object>> buildRubyNamespaces(OpenAPI openAPI) {
Map<String, Map<String, Object>> namespaces = new TreeMap<>();
Map<String, Set<String>> resourcesByNamespace = new TreeMap<>();
Map<String, Set<String>> directOperationsByNamespace = new TreeMap<>();
if (openAPI != null && openAPI.getPaths() != null) {
for (Map.Entry<String, PathItem> pathEntry : openAPI.getPaths().entrySet()) {
PathItem pathItem = pathEntry.getValue();
if (pathItem == null) continue;
for (Map.Entry<PathItem.HttpMethod, Operation> operationEntry : pathItem.readOperationsMap().entrySet()) {
Operation operation = operationEntry.getValue();
if (operation == null || (operation.getExtensions() != null
&& Boolean.TRUE.equals(operation.getExtensions().get("x-internal")))) {
continue;
}
RubyApiRouting.Route route = RubyApiRouting.route(
pathEntry.getKey(), operationEntry.getKey().name(), operation.getOperationId(),
resourceSegments, apiBasePrefix);
Map<String, Object> namespace = namespaces.computeIfAbsent(route.namespace, k -> {
Map<String, Object> data = new HashMap<>();
String base = underscore(sanitizeName(k.replace('-', '_')));
data.put("routeName", k);
data.put("name", base);
data.put("accessor", safeAccessorName(base));
data.put("className", toApiName(k));
data.put("hasDirectOperations", false);
return data;
});
if (route.resource == null) {
namespace.put("hasDirectOperations", true);
directOperationsByNamespace
.computeIfAbsent(route.namespace, k -> new TreeSet<>())
.add(toOperationId(route.action));
} else {
resourcesByNamespace.computeIfAbsent(route.namespace, k -> new TreeSet<>()).add(route.resource);
}
}
}
}

List<Map<String, Object>> result = new ArrayList<>();
for (Map.Entry<String, Map<String, Object>> entry : namespaces.entrySet()) {
Map<String, Object> namespace = entry.getValue();
List<Map<String, Object>> resources = new ArrayList<>();
Set<String> reservedNames = directOperationsByNamespace
.getOrDefault(entry.getKey(), Collections.emptySet());
for (String resource : resourcesByNamespace.getOrDefault(entry.getKey(), Collections.emptySet())) {
Map<String, Object> resourceData = new HashMap<>();
resourceData.put("routeName", resource);
String resourceAccessor = underscore(sanitizeName(resource.replace('-', '_')));
resourceData.put("name", resourceAccessor);
resourceData.put("accessor", safeResourceAccessorName(resourceAccessor, reservedNames));
resourceData.put("className", toApiName(entry.getKey() + "/" + resource));
resources.add(resourceData);
}
namespace.put("resources", resources);
result.add(namespace);
}
return result;
}

private Map<String, Object> findRubyNamespace(String routeName) {
if (routeName == null) {
return null;
}
for (Map<String, Object> namespace : rubyNamespaces) {
if (routeName.equals(namespace.get("routeName"))) {
return namespace;
}
}
return null;
}

private String apiTagForFilename(String templateName, String tag) {
if (!"api.mustache".equals(templateName)) {
return tag;
}
int slash = tag.indexOf('/');
// A namespace with only nested resource groups has no direct API file. Use the
// namespace filename for its resource group so api_operations.mustache can define the
// namespace class before the nested resource class and Zeitwerk can load it as a class.
if (slash > 0 && namespaceOnlyApiTags.contains(tag)) {
return tag.substring(0, slash);
}
return tag;
}

@Override
public String apiFilename(String templateName, String tag) {
return super.apiFilename(templateName, apiTagForFilename(templateName, tag));
}

@Override
public String apiFilename(String templateName, String tag, String outputDir) {
return super.apiFilename(templateName, apiTagForFilename(templateName, tag), outputDir);
}

@Override
public void addOperationToGroup(String tag, String resourcePath, Operation operation,
CodegenOperation co, Map<String, List<CodegenOperation>> operations) {
Expand Down Expand Up @@ -613,11 +714,22 @@ private static String safeAccessorName(String name) {
return RESERVED_ACCESSOR_NAMES.contains(name) ? name + "_api" : name;
}

// Resource methods live on the namespace class beside its constructor and any direct
// operations. Rename a resource accessor deterministically when either would collide.
private static String safeResourceAccessorName(String name, Set<String> reservedNames) {
String candidate = safeAccessorName(name);
while (reservedNames.contains(candidate)) {
candidate += "_api";
}
return candidate;
}

@Override
@SuppressWarnings("unchecked")
public Map<String, Object> postProcessSupportingFileData(Map<String, Object> objs) {
Map<String, Map<String, Object>> nsMap = new TreeMap<>();
Map<String, Set<String>> resourcesByNs = new TreeMap<>();
Map<String, Set<String>> directOperationsByNs = new TreeMap<>();
Map<String, Object> apiInfo = (Map<String, Object>) objs.get("apiInfo");
if (apiInfo != null) {
List<Map<String, Object>> apis = (List<Map<String, Object>>) apiInfo.get("apis");
Expand All @@ -642,7 +754,11 @@ public Map<String, Object> postProcessSupportingFileData(Map<String, Object> obj
m.put("className", toApiName(ns));
return m;
});
if (res != null) resourcesByNs.computeIfAbsent(ns, k -> new TreeSet<>()).add(res);
if (res != null) {
resourcesByNs.computeIfAbsent(ns, k -> new TreeSet<>()).add(res);
} else if (co.operationId != null) {
directOperationsByNs.computeIfAbsent(ns, k -> new TreeSet<>()).add(co.operationId);
}
}
}
}
Expand All @@ -651,9 +767,13 @@ public Map<String, Object> postProcessSupportingFileData(Map<String, Object> obj
for (Map.Entry<String, Map<String, Object>> e : nsMap.entrySet()) {
Map<String, Object> m = e.getValue();
List<Map<String, Object>> resources = new ArrayList<>();
Set<String> reservedNames = directOperationsByNs
.getOrDefault(e.getKey(), Collections.emptySet());
for (String res : resourcesByNs.getOrDefault(e.getKey(), Collections.emptySet())) {
Map<String, Object> rm = new HashMap<>();
rm.put("accessor", underscore(sanitizeName(res.replace('-', '_'))));
String resourceAccessor = underscore(sanitizeName(res.replace('-', '_')));
rm.put("name", resourceAccessor);
rm.put("accessor", safeResourceAccessorName(resourceAccessor, reservedNames));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
rm.put("className", toApiName(e.getKey() + "/" + res));
resources.add(rm);
}
Expand All @@ -680,7 +800,7 @@ public Map<String, Object> postProcessSupportingFileData(Map<String, Object> obj
fileToConstant.putIfAbsent((String) ns.get("name"), lastConstantSegment((String) ns.get("className")));
List<Map<String, Object>> resources = (List<Map<String, Object>>) ns.get("resources");
for (Map<String, Object> res : resources) {
fileToConstant.putIfAbsent((String) res.get("accessor"), lastConstantSegment((String) res.get("className")));
fileToConstant.putIfAbsent((String) res.get("name"), lastConstantSegment((String) res.get("className")));
}
}
List<Map<String, String>> inflections = new ArrayList<>();
Expand Down Expand Up @@ -742,6 +862,34 @@ public ModelsMap postProcessModels(ModelsMap objs) {
public OperationsMap postProcessOperationsWithModels(OperationsMap objs, List<ModelMap> allModels) {
objs = super.postProcessOperationsWithModels(objs, allModels);
OperationMap ops = objs.getOperations();
if (ops != null && !ops.getOperation().isEmpty()) {
CodegenOperation firstOperation = ops.getOperation().get(0);
String namespaceName = (String) firstOperation.vendorExtensions.get("x-rb-namespace");
String resourceName = (String) firstOperation.vendorExtensions.get("x-rb-resource");
Map<String, Object> namespace = findRubyNamespace(namespaceName);
if (namespace != null) {
boolean isNamespaceClass = ops.getClassname().equals(namespace.get("className"));
if (isNamespaceClass) {
ops.put("rbNamespaceHasDirectOperations", true);
ops.put("rbNamespaceResources", namespace.get("resources"));
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
ops.put("rbNamespaceAccessor", namespace.get("accessor"));
} else if (!Boolean.TRUE.equals(namespace.get("hasDirectOperations")) && resourceName != null) {
String resourceTag = namespaceName + "/" + resourceName;
String namespacePrefix = namespaceName + "/";
boolean namespaceFileClaimed = namespaceOnlyApiTags.stream()
.anyMatch(tag -> tag.startsWith(namespacePrefix));
if (!namespaceFileClaimed) {
namespaceOnlyApiTags.add(resourceTag);
}
if (namespaceOnlyApiTags.contains(resourceTag)) {
ops.put("rbNamespaceOnly", true);
ops.put("rbNamespaceClassName", namespace.get("className"));
ops.put("rbNamespaceResources", namespace.get("resources"));
ops.put("rbNamespaceAccessor", namespace.get("accessor"));
}
}
}
}
for (CodegenOperation co : ops.getOperation()) {
String rt;
if (co.returnBaseType == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
{{#rbNamespaceOnly}}
class {{rbNamespaceClassName}}
def initialize(connection)
@connection = connection
end
{{#rbNamespaceResources}}

def {{accessor}}
@{{accessor}} ||= {{className}}.new(@connection)
end
{{/rbNamespaceResources}}
end

{{/rbNamespaceOnly}}
class {{classname}}
def initialize(connection)
@connection = connection
Expand Down Expand Up @@ -34,4 +48,12 @@ class {{classname}}
)
end
{{/operation}}
end
{{#rbNamespaceHasDirectOperations}}
{{#rbNamespaceResources}}

def {{accessor}}
@{{accessor}} ||= {{className}}.new(@connection)
end
{{/rbNamespaceResources}}
{{/rbNamespaceHasDirectOperations}}
end
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,21 @@ RSpec.describe {{moduleName}}::{{#apiNamespacePresent}}{{apiNamespace}}::{{/apiN
api = described_class.new(client.connection)
expect(api).to be_a(described_class)
end
{{#rbNamespaceHasDirectOperations}}
{{#rbNamespaceResources}}

it 'is reachable through the namespace client' do
expect(client.{{rbNamespaceAccessor}}.{{accessor}}).to be_a({{moduleName}}::{{#apiNamespacePresent}}{{apiNamespace}}::{{/apiNamespacePresent}}{{className}})
end
{{/rbNamespaceResources}}
{{/rbNamespaceHasDirectOperations}}
{{#rbNamespaceOnly}}
{{#rbNamespaceResources}}

it 'is reachable through the namespace client' do
expect(client.{{rbNamespaceAccessor}}.{{accessor}}).to be_a({{moduleName}}::{{#apiNamespacePresent}}{{apiNamespace}}::{{/apiNamespacePresent}}{{className}})
end
{{/rbNamespaceResources}}
{{/rbNamespaceOnly}}
end
{{/operations}}
Original file line number Diff line number Diff line change
Expand Up @@ -390,12 +390,46 @@ public void testAcronymModelsGetZeitwerkInflections() throws Exception {
// and the model file itself must define the acronym-cased constant
org.openapitools.codegen.TestUtils.assertFileContains(
target.resolve("lib/acme/models/http_config.rb"), "HTTPConfig");
// Acronyms in API resource classes must be registered too: the file
// api/dedicated_cloud/two_fa_whitelist.rb defines DedicatedCloud::TwoFAWhitelist,
// which the default inflector (expecting TwoFaWhitelist) would fail to autoload.
// Acronyms in API resource classes must be registered too. In a namespace with
// nested resources only, the first resource group's file also defines the namespace
// class, so it is emitted as api/dedicated_cloud.rb rather than as a separate
// two_fa_whitelist.rb file.
org.openapitools.codegen.TestUtils.assertFileContains(
target.resolve("lib/acme.rb"), "\"two_fa_whitelist\" => \"TwoFAWhitelist\"");
org.openapitools.codegen.TestUtils.assertFileContains(
target.resolve("lib/acme/api/dedicated_cloud/two_fa_whitelist.rb"), "TwoFAWhitelist");
target.resolve("lib/acme/api/dedicated_cloud.rb"), "class DedicatedCloud::TwoFAWhitelist");
}

@Test
public void testNestedResourcesAreReachableFromClient() throws Exception {
java.nio.file.Path target = java.nio.file.Files.createTempDirectory("test");
target.toFile().deleteOnExit();
org.openapitools.codegen.ClientOptInput input =
new org.openapitools.codegen.config.CodegenConfigurator()
.setGeneratorName("ruby-nextgen")
.setInputSpec("src/test/resources/3_0/ruby-nextgen/nested-resources.yaml")
.setOutputDir(target.toString())
.addAdditionalProperty("gemName", "petstore")
.addAdditionalProperty("moduleName", "Petstore")
.toClientOptInput();
new org.openapitools.codegen.DefaultGenerator(false).opts(input).generate();

org.openapitools.codegen.TestUtils.assertFileContains(
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
target.resolve("lib/petstore/client.rb"), "def stables", "Petstore::Api::Stables.new",
"def only", "Petstore::Api::Only.new");
org.openapitools.codegen.TestUtils.assertFileContains(
target.resolve("lib/petstore/api/stables.rb"),
"def ponies", "@ponies ||= Stables::Ponies.new(@connection)");
org.openapitools.codegen.TestUtils.assertFileExists(
target.resolve("lib/petstore/api/stables/ponies.rb"));

java.nio.file.Path namespaceOnly = target.resolve("lib/petstore/api/only.rb");
org.openapitools.codegen.TestUtils.assertFileContains(
namespaceOnly, "class Only", "def children", "@children ||= Only::Children.new(@connection)",
"def siblings", "@siblings ||= Only::Siblings.new(@connection)", "class Only::Children");
org.openapitools.codegen.TestUtils.assertFileNotExists(
target.resolve("lib/petstore/api/only/children.rb"));
org.openapitools.codegen.TestUtils.assertFileExists(
target.resolve("lib/petstore/api/only/siblings.rb"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
openapi: 3.0.3
info:
title: Nested resources
version: 1.0.0
paths:
/stables:
get:
operationId: listStables
responses:
'200':
description: OK
/stables/{stable}:
get:
operationId: getStable
parameters:
- name: stable
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/stables/{stable}/ponies/{pony}:
get:
operationId: getPony
parameters:
- name: stable
in: path
required: true
schema:
type: string
- name: pony
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/only/{only}/children/{child}:
get:
operationId: getChild
parameters:
- name: only
in: path
required: true
schema:
type: string
- name: child
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
/only/{only}/siblings/{sibling}:
get:
operationId: getSibling
parameters:
- name: only
in: path
required: true
schema:
type: string
- name: sibling
in: path
required: true
schema:
type: string
responses:
'200':
description: OK
Loading
Loading