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
176 changes: 171 additions & 5 deletions src/main/java/com/twilio/oai/DirectoryStructureService.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;

import static com.twilio.oai.common.ApplicationConstants.*;

Expand All @@ -41,6 +42,7 @@ public class DirectoryStructureService {
@Getter
private boolean isVersionLess = false;
private final Map<String, String> productMap = new HashMap<>();
private OpenAPI openAPI;
private final List<CodegenModel> allModels = new ArrayList<>();
private final List<Object> dependentList = new ArrayList<>();

Expand All @@ -60,6 +62,11 @@ public static class DependentResource {
private String listName;
private boolean listWithPathParams;

// New fields for function overload support
private boolean hasInstanceOperation;
private String instanceParam;
private String instanceType;

public boolean getHasPathParams() {
return pathParams != null && !pathParams.isEmpty();
}
Expand All @@ -78,10 +85,15 @@ public static class ContextResource {
}

public void configure(final OpenAPI openAPI) {
this.openAPI = openAPI;
final Map<String, DependentResource> versionResources = getVersionResourcesMap();
Map<String, PathItem> pathsToSkipMap = new HashMap<>();

isVersionLess = additionalProperties.getOrDefault(API_VERSION, "").equals("");
additionalProperties.put("isVersionLessSpec", isVersionLess ? "true" : "false");

final boolean isV1ApiSpec = ResourceCacheContext.get() != null && ResourceCacheContext.get().isV1();
additionalProperties.put("isV1ApiSpec", isV1ApiSpec ? "true" : "false");

openAPI.getPaths().forEach(resourceTree::addResource);
openAPI.getPaths().forEach((name, path) -> {
Expand All @@ -104,7 +116,7 @@ public void configure(final OpenAPI openAPI) {
operation.addTagsItem(tag);

if (!tag.contains(PATH_SEPARATOR_PLACEHOLDER)) {
final DependentResource dependent = generateDependent(name, operation);
final DependentResource dependent = generateDependent(name, path, operation);
final boolean isIgnoredOperation = Optional.ofNullable(operation.getExtensions())
.map(ext -> ext.get(IGNORE_EXTENSION_NAME))
.map(Boolean.class::cast)
Expand Down Expand Up @@ -137,15 +149,113 @@ public void configure(final OpenAPI openAPI) {
}

public void addVersionResources(DependentResource dependent, Map<String, DependentResource> versionResources) {
final boolean isV1ApiSpec = "true".equals(additionalProperties.get("isV1ApiSpec"));
if (versionResources.containsKey(dependent.getFilename())) {
DependentResource existingDependent = versionResources.get(dependent.getFilename());
if (existingDependent.getPathParams().size() == 0)
// For v1Api specs: also replace when new entry has listWithPathParams=true and existing
// does not — handles instance path processed before list path (spec ordering issue)
if (existingDependent.getPathParams().isEmpty()
|| (isV1ApiSpec && dependent.isListWithPathParams() && !existingDependent.isListWithPathParams())) {
versionResources.put(dependent.getFilename(), dependent);
}
} else {
versionResources.put(dependent.getFilename(), dependent);
}
}

/**
* Enriches version resources with instance operation detection.
* For resources that have both list and instance operations, sets:
* - hasInstanceOperation: true
* - instanceParam: the instance parameter name (e.g., "summaryId")
* - instanceType: the Context type name (e.g., "ConversationSummaryContext")
*/
private void enrichVersionResourcesWithInstanceOperations(List<DependentResource> versionResources) {
for (DependentResource resource : versionResources) {
// Find matching resource in resourceTree to check for instance operations
Optional<Resource> resourceOptional = StreamSupport.stream(
resourceTree.getResources().spliterator(), false)
.filter(r -> caseResolver.filenameOperation(r.getResourceAliases().getClassName()).equals(resource.getFilename()))
.findFirst();

if (resourceOptional.isEmpty()) {
continue;
}

Resource treeResource = resourceOptional.get();

// Check if this resource has instance operations by looking for pathType: instance
boolean hasInstanceOp = openAPI.getPaths().entrySet().stream()
.filter(entry -> {
String pathKey = entry.getKey();
PathItem pathItem = entry.getValue();
// Check if this path belongs to the same resource
return pathItem.readOperations().stream()
.anyMatch(op -> {
String tag = String.join(PATH_SEPARATOR_PLACEHOLDER, resourceTree.ancestors(pathKey, op));
// Compare the tag (converted to filename format) with the resource filename
String tagFilename = caseResolver.filenameOperation(tag);
return tagFilename.equals(resource.getFilename());
});
})
.anyMatch(entry -> {
PathItem pathItem = entry.getValue();
Optional<String> pathType = PathUtils.getTwilioExtension(pathItem, "pathType");
return pathType.isPresent() && pathType.get().equals("instance");
});

if (hasInstanceOp && resource.isListWithPathParams()) {
resource.setHasInstanceOperation(true);

// Find the instance parameter by looking at instance paths
String instanceParam = openAPI.getPaths().entrySet().stream()
.filter(entry -> {
PathItem pathItem = entry.getValue();
Optional<String> pathType = PathUtils.getTwilioExtension(pathItem, "pathType");
if (pathType.isEmpty() || !pathType.get().equals("instance")) {
return false;
}

return pathItem.readOperations().stream()
.anyMatch(op -> {
String pathKey = entry.getKey();
String tag = String.join(PATH_SEPARATOR_PLACEHOLDER, resourceTree.ancestors(pathKey, op));
String tagFilename = caseResolver.filenameOperation(tag);
return tagFilename.equals(resource.getFilename());
});
})
.findFirst()
.map(entry -> {
// Get the last path parameter from instance path
String path = entry.getKey();
List<Parameter> allParams = Optional.ofNullable(entry.getValue().readOperations().get(0).getParameters())
.orElse(Collections.emptyList());

List<Parameter> pathParams = allParams.stream()
.filter(p -> "path".equals(p.getIn()))
.collect(Collectors.toList());

// The instance param is the one not in listWithPathParams
Set<String> listParamNames = resource.getPathParams().stream()
.map(Parameter::getName)
.collect(Collectors.toSet());

return pathParams.stream()
.map(Parameter::getName)
.filter(name -> !listParamNames.contains(name))
.findFirst()
.orElse(null);
})
.orElse(null);

if (instanceParam != null) {
resource.setInstanceParam(caseResolver.pathOperation(instanceParam));
resource.setInstanceType(resource.getResourceName() + "Context");
}
}
}
}

@SuppressWarnings("unchecked")
public Map<String, DependentResource> getVersionResourcesMap() {
return (Map<String, DependentResource>) additionalProperties.computeIfAbsent(ALL_VERSION_RESOURCES,
Expand Down Expand Up @@ -183,8 +293,12 @@ private Stream<Parameter> getParamStream(final Operation operation) {
}

public DependentResource generateDependent(final String path, final Operation operation) {
return generateDependent(path, null, operation);
}

public DependentResource generateDependent(final String path, final PathItem pathItem, final Operation operation) {
final Resource.Aliases resourceAliases = getResourceAliases(path, operation);
List<Parameter> params = fetchNonParentPathParams(operation);
List<Parameter> params = fetchNonParentPathParams(pathItem, operation);
return new DependentResource.DependentResourceBuilder()
.version(PathUtils.getFirstPathPart(path))
.type(resourceAliases.getClassName() + LIST_INSTANCE)
Expand Down Expand Up @@ -225,10 +339,38 @@ public void addContextdependents(final List<Object> resourceList, final String p
}

private List<Parameter> fetchNonParentPathParams(Operation operation) {
return fetchNonParentPathParams(null, operation);
}

private List<Parameter> fetchNonParentPathParams(PathItem pathItem, Operation operation) {
List<Parameter> params = new ArrayList<>();
if (null == operation) return params;
List<Parameter> pathParams = Optional.ofNullable(operation.getParameters())
.stream().flatMap(Collection::stream)

// For v1Api specs: merge path-item level params (which may use $ref components) with
// operation-level params so nested list resources can detect their path parameters.
final boolean isV1ApiSpec = "true".equals(additionalProperties.get("isV1ApiSpec"));
List<Parameter> allParams = new ArrayList<>();
if (isV1ApiSpec && pathItem != null && pathItem.getParameters() != null) {
pathItem.getParameters().stream()
.map(this::resolveParameterRef)
.filter(Objects::nonNull)
.forEach(allParams::add);
}
if (operation.getParameters() != null) {
Set<String> operationParamNames = operation.getParameters().stream()
.map(this::resolveParameterRef)
.filter(Objects::nonNull)
.filter(p -> p.getName() != null)
.map(Parameter::getName)
.collect(Collectors.toSet());
allParams.removeIf(p -> p.getName() != null && operationParamNames.contains(p.getName()));
operation.getParameters().stream()
.map(this::resolveParameterRef)
.filter(Objects::nonNull)
.forEach(allParams::add);
}

List<Parameter> pathParams = allParams.stream()
.filter(param -> Objects.nonNull(param.getIn())).filter(PathUtils::isPathParam)
.collect(Collectors.toList());
params = pathParams.stream().filter(parameter -> Objects.isNull(parameter.getExtensions()))
Expand All @@ -239,6 +381,26 @@ private List<Parameter> fetchNonParentPathParams(Operation operation) {
return params;
}

/**
* Resolves a $ref parameter stub to its full definition from openAPI components.
* Returns the parameter as-is if it is already fully defined (no $ref).
*/
private Parameter resolveParameterRef(Parameter param) {
if (param == null) return null;
if (param.get$ref() != null && param.getIn() == null) {
// extract component name from e.g. "#/components/parameters/StoreId"
String ref = param.get$ref();
String componentName = ref.substring(ref.lastIndexOf('/') + 1);
if (openAPI != null
&& openAPI.getComponents() != null
&& openAPI.getComponents().getParameters() != null) {
return openAPI.getComponents().getParameters().get(componentName);
}
return null;
}
return param;
}

private Resource.Aliases getResourceAliases(final String path, final Operation operation) {
return resourceTree
.findResource(path)
Expand Down Expand Up @@ -293,6 +455,10 @@ public List<CodegenOperation> processOperations(final OperationsMap results) {
.stream()
.filter(resource -> resource.getVersion().equals(version))
.collect(Collectors.toList());

// Detect instance operations for each version resource
enrichVersionResourcesWithInstanceOperations(versionResources);

additionalProperties.put(VERSION_RESOURCES, versionResources);

if (((String)additionalProperties.get(API_VERSION)).equalsIgnoreCase("v2010")) {
Expand Down
10 changes: 9 additions & 1 deletion src/main/java/com/twilio/oai/TwilioNodeGenerator.java
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,17 @@ public String getHelp() {

@Override
public String toParamName(final String name) {
return Arrays
String paramName = Arrays
.stream(twilioCodegen.toParamName(name).split("\\."))
.map(input -> StringHelper.camelize(input, true))
.collect(Collectors.joining("."));

// Rename 'version' path param to avoid collision with Version instance variable
// Used in generated code like: function VersionListInstance(version: V3, id: string)
if ("version".equals(paramName)) {
return "versionParam";
}

return paramName;
}
}
87 changes: 87 additions & 0 deletions src/main/java/com/twilio/oai/api/ApiResourceBuilder.java
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,24 @@ protected Map<String, Object> mapOperation(CodegenOperation operation) {
addOperationName(operation, Operation.FETCH.getValue());
} else if (StringUtils.startsWithIgnoreCase(operation.operationId, "list")) {
addOperationName(operation, Operation.READ.getValue());
// Add flag to indicate if pagination is supported (for Node.js conditional pagination)
boolean supportsPagination = hasMetaInResponse(operation);
operationMap.put("x-supports-pagination", supportsPagination);

// Check if array items are primitives (strings, numbers) vs objects
boolean isPrimitiveArray = isRecordKeyArrayOfPrimitives(operation);

// For non-paginated endpoints with primitive arrays: return string[] directly
boolean arrayItemsArePrimitives = !supportsPagination && isPrimitiveArray;
operationMap.put("x-array-items-are-primitives", arrayItemsArePrimitives);

// For paginated endpoints with primitive arrays: Page exists but instances are primitives
boolean paginatedPrimitiveItems = supportsPagination && isPrimitiveArray;
operationMap.put("x-paginated-primitive-items", paginatedPrimitiveItems);
// Also add to metaAPIProperties so it's available at resource level
if (paginatedPrimitiveItems) {
metaAPIProperties.put("x-paginated-primitive-items", true);
}
} else if (StringUtils.startsWithIgnoreCase(operation.operationId, "patch")) {
addOperationName(operation, Operation.PATCH.getValue());
}
Expand Down Expand Up @@ -316,6 +334,75 @@ public boolean hasPaginationOperation() {
return codegenOperationList.stream().anyMatch(co -> co.operationId.toLowerCase().startsWith("list"));
}

/**
* Check if any operation in this resource supports pagination (has meta in response).
* This is used for conditional Page class generation in Node.js.
*
* @return true if at least one operation has x-supports-pagination vendor extension set to true
*/
public boolean hasAnyOperationSupportingPagination() {
return codegenOperationList.stream()
.anyMatch(co -> Boolean.TRUE.equals(co.vendorExtensions.get("x-supports-pagination")));
}

/**
* Check if a read operation supports pagination by verifying the response schema has a 'meta' property.
* This is used to conditionally generate pagination methods (page, each, getPage) for Node.js.
*
* @param operation The CodegenOperation to check
* @return true if the operation's response schema contains a 'meta' property, false otherwise
*/
protected boolean hasMetaInResponse(CodegenOperation operation) {
// Only check for read (list) operations
if (!operation.operationId.toLowerCase().startsWith("list")) {
return false;
}

// Check if the response schema has a 'meta' property
// The response model should contain a 'meta' field alongside the data array
Optional<CodegenModel> responseModel = getModelByClassname(operation.returnBaseType);

if (responseModel.isPresent()) {
return responseModel.get().getAllVars().stream()
.anyMatch(var -> var.baseName.equals("meta"));
}

return false;
}

/**
* Check if the recordKey array contains primitive values (strings, numbers) instead of objects.
* This is needed to generate correct code for endpoints like Profile Imports that return
* arrays of ImportID strings rather than ImportInstance objects.
*
* @param operation The list operation to check
* @return true if the array items are primitives, false if they are objects
*/
protected boolean isRecordKeyArrayOfPrimitives(CodegenOperation operation) {
// Only check for read (list) operations
if (!operation.operationId.toLowerCase().startsWith("list") || recordKey == null) {
return false;
}

// Get the response model
Optional<CodegenModel> responseModel = getModelByClassname(operation.returnBaseType);

if (responseModel.isPresent()) {
// Find the recordKey property in the response model
Optional<CodegenProperty> recordKeyProp = responseModel.get().getAllVars().stream()
.filter(var -> var.baseName.equals(recordKey))
.findFirst();

if (recordKeyProp.isPresent()) {
// If complexType is null, it means the array contains primitives (strings, numbers, etc.)
// If complexType is present, the array contains objects
return recordKeyProp.get().getComplexType() == null;
}
}

return false;
}

public String getApiName() {
final List<String> filePathArray = new ArrayList<>(Arrays.asList(codegenOperationList.get(0).baseName.split(
PATH_SEPARATOR_PLACEHOLDER)));
Expand Down
Loading
Loading