Migrate users of Optional.get()
[netconf.git] / restconf / sal-rest-docgen / src / main / java / org / opendaylight / netconf / sal / rest / doc / impl / BaseYangSwaggerGenerator.java
1 /*
2  * Copyright (c) 2014 Brocade Communications Systems, Inc. and others.  All rights reserved.
3  *
4  * This program and the accompanying materials are made available under the
5  * terms of the Eclipse Public License v1.0 which accompanies this distribution,
6  * and is available at http://www.eclipse.org/legal/epl-v10.html
7  */
8 package org.opendaylight.netconf.sal.rest.doc.impl;
9
10 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.TOP;
11 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildDelete;
12 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildGet;
13 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildPatch;
14 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildPost;
15 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildPostOperation;
16 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.buildPut;
17 import static org.opendaylight.netconf.sal.rest.doc.model.builder.OperationBuilder.getTypeParentNode;
18 import static org.opendaylight.netconf.sal.rest.doc.util.JsonUtil.addFields;
19 import static org.opendaylight.netconf.sal.rest.doc.util.RestDocgenUtil.resolvePathArgumentsName;
20
21 import com.fasterxml.jackson.databind.JsonNode;
22 import com.fasterxml.jackson.databind.ObjectMapper;
23 import com.fasterxml.jackson.databind.SerializationFeature;
24 import com.fasterxml.jackson.databind.node.ArrayNode;
25 import com.fasterxml.jackson.databind.node.JsonNodeFactory;
26 import com.fasterxml.jackson.databind.node.ObjectNode;
27 import com.google.common.base.Preconditions;
28 import com.google.common.collect.ImmutableList;
29 import com.google.common.collect.Range;
30 import java.io.IOException;
31 import java.time.format.DateTimeParseException;
32 import java.util.Collection;
33 import java.util.Collections;
34 import java.util.HashMap;
35 import java.util.Iterator;
36 import java.util.List;
37 import java.util.Map;
38 import java.util.Map.Entry;
39 import java.util.Optional;
40 import java.util.Set;
41 import java.util.SortedSet;
42 import java.util.TreeSet;
43 import javax.ws.rs.core.MediaType;
44 import javax.ws.rs.core.UriInfo;
45 import org.opendaylight.mdsal.dom.api.DOMSchemaService;
46 import org.opendaylight.netconf.sal.rest.doc.swagger.CommonApiObject;
47 import org.opendaylight.netconf.sal.rest.doc.swagger.Components;
48 import org.opendaylight.netconf.sal.rest.doc.swagger.Info;
49 import org.opendaylight.netconf.sal.rest.doc.swagger.OpenApiObject;
50 import org.opendaylight.netconf.sal.rest.doc.swagger.SecurityDefinitions;
51 import org.opendaylight.netconf.sal.rest.doc.swagger.SecuritySchemes;
52 import org.opendaylight.netconf.sal.rest.doc.swagger.Server;
53 import org.opendaylight.netconf.sal.rest.doc.swagger.SwaggerObject;
54 import org.opendaylight.netconf.sal.rest.doc.util.JsonUtil;
55 import org.opendaylight.yangtools.yang.common.QName;
56 import org.opendaylight.yangtools.yang.common.Revision;
57 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
58 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
59 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
60 import org.opendaylight.yangtools.yang.model.api.ActionNodeContainer;
61 import org.opendaylight.yangtools.yang.model.api.ContainerSchemaNode;
62 import org.opendaylight.yangtools.yang.model.api.DataNodeContainer;
63 import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
64 import org.opendaylight.yangtools.yang.model.api.EffectiveModelContext;
65 import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
66 import org.opendaylight.yangtools.yang.model.api.Module;
67 import org.opendaylight.yangtools.yang.model.api.OperationDefinition;
68 import org.opendaylight.yangtools.yang.model.api.RpcDefinition;
69 import org.slf4j.Logger;
70 import org.slf4j.LoggerFactory;
71
72 public abstract class BaseYangSwaggerGenerator {
73
74     private static final Logger LOG = LoggerFactory.getLogger(BaseYangSwaggerGenerator.class);
75
76     private static final String API_VERSION = "1.0.0";
77     private static final String SWAGGER_VERSION = "2.0";
78     private static final String OPEN_API_VERSION = "3.0.3";
79     private static final ObjectMapper MAPPER = new ObjectMapper();
80     private static final List<String> PRODUCES = List.of(MediaType.APPLICATION_XML, MediaType.APPLICATION_JSON);
81
82     private final DefinitionGenerator jsonConverter = new DefinitionGenerator();
83     private final DOMSchemaService schemaService;
84
85     public static final String BASE_PATH = "/";
86     public static final String MODULE_NAME_SUFFIX = "_module";
87
88     private static final ObjectNode SWAGGER_BASIC_AUTH = JsonNodeFactory.instance.objectNode()
89             .put("type", "basic");
90     private static final ObjectNode OPEN_API_BASIC_AUTH = JsonNodeFactory.instance.objectNode()
91             .put("type", "http")
92             .put("scheme", "basic");
93     private static final ArrayNode SECURITY = JsonNodeFactory.instance.arrayNode()
94             .add(JsonNodeFactory.instance.objectNode().set("basicAuth", JsonNodeFactory.instance.arrayNode()));
95
96     static {
97         MAPPER.configure(SerializationFeature.INDENT_OUTPUT, true);
98     }
99
100     protected BaseYangSwaggerGenerator(final Optional<DOMSchemaService> schemaService) {
101         this.schemaService = schemaService.orElse(null);
102     }
103
104     public SwaggerObject getAllModulesDoc(final UriInfo uriInfo, final DefinitionNames definitionNames) {
105         final EffectiveModelContext schemaContext = schemaService.getGlobalContext();
106         Preconditions.checkState(schemaContext != null);
107         return getAllModulesDoc(uriInfo, Optional.empty(), schemaContext, Optional.empty(), "", definitionNames);
108     }
109
110     public SwaggerObject getAllModulesDoc(final UriInfo uriInfo, final Optional<Range<Integer>> range,
111             final EffectiveModelContext schemaContext, final Optional<String> deviceName, final String context,
112             final DefinitionNames definitionNames) {
113         final String schema = createSchemaFromUriInfo(uriInfo);
114         final String host = createHostFromUriInfo(uriInfo);
115         String name = "Controller";
116         if (deviceName.isPresent()) {
117             name = deviceName.orElseThrow();
118         }
119
120         final String title = name + " modules of RESTCONF";
121         final SwaggerObject doc = createSwaggerObject(schema, host, BASE_PATH, title);
122         doc.setDefinitions(JsonNodeFactory.instance.objectNode());
123         doc.setPaths(JsonNodeFactory.instance.objectNode());
124
125         fillDoc(doc, range, schemaContext, context, deviceName, definitionNames);
126
127         return doc;
128     }
129
130     public void fillDoc(final SwaggerObject doc, final Optional<Range<Integer>> range,
131             final EffectiveModelContext schemaContext, final String context, final Optional<String> deviceName,
132             final DefinitionNames definitionNames) {
133         final SortedSet<Module> modules = getSortedModules(schemaContext);
134         final Set<Module> filteredModules;
135         if (range.isPresent()) {
136             filteredModules = filterByRange(modules, range.orElseThrow());
137         } else {
138             filteredModules = modules;
139         }
140
141         for (final Module module : filteredModules) {
142             final String revisionString = module.getQNameModule().getRevision().map(Revision::toString).orElse(null);
143
144             LOG.debug("Working on [{},{}]...", module.getName(), revisionString);
145
146             getSwaggerDocSpec(module, context, deviceName, schemaContext, definitionNames, doc, false);
147         }
148     }
149
150     private static Set<Module> filterByRange(final SortedSet<Module> modules, final Range<Integer> range) {
151         final int begin = range.lowerEndpoint();
152         final int end = range.upperEndpoint();
153
154         Module firstModule = null;
155
156         final Iterator<Module> iterator = modules.iterator();
157         int counter = 0;
158         while (iterator.hasNext() && counter < end) {
159             final Module module = iterator.next();
160             if (containsListOrContainer(module.getChildNodes()) || !module.getRpcs().isEmpty()) {
161                 if (counter == begin) {
162                     firstModule = module;
163                 }
164                 counter++;
165             }
166         }
167
168         if (iterator.hasNext()) {
169             return modules.subSet(firstModule, iterator.next());
170         } else {
171             return modules.tailSet(firstModule);
172         }
173     }
174
175     public CommonApiObject getApiDeclaration(final String module, final String revision, final UriInfo uriInfo) {
176         final EffectiveModelContext schemaContext = schemaService.getGlobalContext();
177         Preconditions.checkState(schemaContext != null);
178         final SwaggerObject doc = getApiDeclaration(module, revision, uriInfo, schemaContext, "");
179         return convertToOpenApi(doc);
180     }
181
182     public SwaggerObject getApiDeclaration(final String moduleName, final String revision, final UriInfo uriInfo,
183             final EffectiveModelContext schemaContext, final String context) {
184         final Optional<Revision> rev;
185
186         try {
187             rev = Revision.ofNullable(revision);
188         } catch (final DateTimeParseException e) {
189             throw new IllegalArgumentException(e);
190         }
191
192         final Module module = schemaContext.findModule(moduleName, rev).orElse(null);
193         Preconditions.checkArgument(module != null,
194                 "Could not find module by name,revision: " + moduleName + "," + revision);
195
196         return getApiDeclaration(module, uriInfo, context, schemaContext);
197     }
198
199     public SwaggerObject getApiDeclaration(final Module module, final UriInfo uriInfo, final String context,
200             final EffectiveModelContext schemaContext) {
201         final String schema = createSchemaFromUriInfo(uriInfo);
202         final String host = createHostFromUriInfo(uriInfo);
203
204         return getSwaggerDocSpec(module, schema, host, BASE_PATH, context, schemaContext);
205     }
206
207     public String createHostFromUriInfo(final UriInfo uriInfo) {
208         String portPart = "";
209         final int port = uriInfo.getBaseUri().getPort();
210         if (port != -1) {
211             portPart = ":" + port;
212         }
213         return uriInfo.getBaseUri().getHost() + portPart;
214     }
215
216     public String createSchemaFromUriInfo(final UriInfo uriInfo) {
217         return uriInfo.getBaseUri().getScheme();
218     }
219
220     public SwaggerObject getSwaggerDocSpec(final Module module, final String schema, final String host,
221             final String basePath, final String context, final EffectiveModelContext schemaContext) {
222         final SwaggerObject doc = createSwaggerObject(schema, host, basePath, module.getName());
223         final DefinitionNames definitionNames = new DefinitionNames();
224         return getSwaggerDocSpec(module, context, Optional.empty(), schemaContext, definitionNames, doc, true);
225     }
226
227     public SwaggerObject getSwaggerDocSpec(final Module module, final String context, final Optional<String> deviceName,
228             final EffectiveModelContext schemaContext, final DefinitionNames definitionNames, final SwaggerObject doc,
229             final boolean isForSingleModule) {
230         final ObjectNode definitions;
231
232         try {
233             if (isForSingleModule) {
234                 definitions = jsonConverter.convertToJsonSchema(module, schemaContext, definitionNames, true);
235                 doc.setDefinitions(definitions);
236             } else {
237                 definitions = jsonConverter.convertToJsonSchema(module, schemaContext, definitionNames, false);
238                 addFields(doc.getDefinitions(), definitions.fields());
239             }
240             if (LOG.isDebugEnabled()) {
241                 LOG.debug("Document: {}", MAPPER.writeValueAsString(doc));
242             }
243         } catch (final IOException e) {
244             LOG.error("Exception occured in DefinitionGenerator", e);
245         }
246
247         final ObjectNode paths = JsonNodeFactory.instance.objectNode();
248         final String moduleName = module.getName();
249
250         boolean hasAddRootPostLink = false;
251
252         final Collection<? extends DataSchemaNode> dataSchemaNodes = module.getChildNodes();
253         LOG.debug("child nodes size [{}]", dataSchemaNodes.size());
254         for (final DataSchemaNode node : dataSchemaNodes) {
255             if (node instanceof ListSchemaNode || node instanceof ContainerSchemaNode) {
256                 LOG.debug("Is Configuration node [{}] [{}]", node.isConfiguration(), node.getQName().getLocalName());
257
258                 final String localName = module.getName() + ":" + node.getQName().getLocalName();
259                 ArrayNode pathParams = JsonNodeFactory.instance.arrayNode();
260                 String resourcePath;
261
262                 if (node.isConfiguration()) { // This node's config statement is
263                     // true.
264                     resourcePath = getResourcePath("config", context);
265
266                     /*
267                      * When there are two or more top container or list nodes
268                      * whose config statement is true in module, make sure that
269                      * only one root post link is added for this module.
270                      */
271                     if (isForSingleModule && !hasAddRootPostLink) {
272                         LOG.debug("Has added root post link for module {}", module.getName());
273                         addRootPostLink(module, deviceName, pathParams, resourcePath, paths);
274
275                         hasAddRootPostLink = true;
276                     }
277
278                     final String resolvedPath = resourcePath + "/" + createPath(node, pathParams, localName);
279                     addPaths(node, deviceName, moduleName, paths, pathParams, schemaContext, true, module.getName(),
280                         definitionNames, resolvedPath);
281                 }
282                 pathParams = JsonNodeFactory.instance.arrayNode();
283                 resourcePath = getResourcePath("operational", context);
284
285                 if (!node.isConfiguration()) {
286                     final String resolvedPath = resourcePath + "/" + createPath(node, pathParams, localName);
287                     addPaths(node, deviceName, moduleName, paths, pathParams, schemaContext, false, moduleName,
288                         definitionNames, resolvedPath);
289                 }
290             }
291         }
292
293         for (final RpcDefinition rpcDefinition : module.getRpcs()) {
294             final String resolvedPath = getResourcePath("operations", context) + "/" + moduleName + ":"
295                     + rpcDefinition.getQName().getLocalName();
296             addOperations(rpcDefinition, moduleName, deviceName, paths, module.getName(), definitionNames,
297                 resolvedPath);
298         }
299
300         LOG.debug("Number of Paths found [{}]", paths.size());
301
302         if (isForSingleModule) {
303             doc.setPaths(paths);
304         } else {
305             addFields(doc.getPaths(), paths.fields());
306         }
307
308         return doc;
309     }
310
311     private static void addRootPostLink(final Module module, final Optional<String> deviceName,
312             final ArrayNode pathParams, final String resourcePath, final ObjectNode paths) {
313         if (containsListOrContainer(module.getChildNodes())) {
314             final ObjectNode post = JsonNodeFactory.instance.objectNode();
315             final String moduleName = module.getName();
316             final String name = moduleName + MODULE_NAME_SUFFIX;
317             post.set("post", buildPost("", name, "", moduleName, deviceName,
318                     module.getDescription().orElse(""), pathParams));
319             paths.set(resourcePath, post);
320         }
321     }
322
323     public SwaggerObject createSwaggerObject(final String schema, final String host, final String basePath,
324             final String title) {
325         final SwaggerObject doc = new SwaggerObject();
326         doc.setSwagger(SWAGGER_VERSION);
327         final Info info = new Info();
328         info.setTitle(title);
329         info.setVersion(API_VERSION);
330         doc.setInfo(info);
331         doc.setSchemes(ImmutableList.of(schema));
332         doc.setHost(host);
333         doc.setBasePath(basePath);
334         doc.setProduces(PRODUCES);
335         doc.setSecurityDefinitions(new SecurityDefinitions(SWAGGER_BASIC_AUTH));
336         doc.setSecurity(SECURITY);
337         return doc;
338     }
339
340     public static OpenApiObject convertToOpenApi(final SwaggerObject swaggerObject) {
341         final OpenApiObject doc = new OpenApiObject();
342         doc.setOpenapi(OPEN_API_VERSION);
343         doc.setInfo(swaggerObject.getInfo());
344         doc.setServers(convertToServers(swaggerObject.getSchemes(), swaggerObject.getHost(),
345                 swaggerObject.getBasePath()));
346         doc.setPaths(swaggerObject.getPaths());
347         doc.setComponents(new Components(swaggerObject.getDefinitions(), new SecuritySchemes(OPEN_API_BASIC_AUTH)));
348         doc.setSecurity(swaggerObject.getSecurity());
349         return doc;
350     }
351
352     private static List<Server> convertToServers(final List<String> schemes, final String host, final String basePath) {
353         return ImmutableList.of(new Server(schemes.get(0) + "://" + host + basePath));
354     }
355
356     public abstract String getResourcePath(String resourceType, String context);
357
358     public abstract String getResourcePathPart(String resourceType);
359
360     private void addPaths(final DataSchemaNode node, final Optional<String> deviceName, final String moduleName,
361             final ObjectNode paths, final ArrayNode parentPathParams, final EffectiveModelContext schemaContext,
362             final boolean isConfig, final String parentName, final DefinitionNames definitionNames,
363             final String resourcePath) {
364         LOG.debug("Adding path: [{}]", resourcePath);
365
366         final ArrayNode pathParams = JsonUtil.copy(parentPathParams);
367         Iterable<? extends DataSchemaNode> childSchemaNodes = Collections.emptySet();
368         if (node instanceof ListSchemaNode || node instanceof ContainerSchemaNode) {
369             final DataNodeContainer dataNodeContainer = (DataNodeContainer) node;
370             childSchemaNodes = dataNodeContainer.getChildNodes();
371         }
372
373         final ObjectNode path = JsonNodeFactory.instance.objectNode();
374         path.setAll(operations(node, moduleName, deviceName, pathParams, isConfig, parentName, definitionNames));
375         paths.set(resourcePath, path);
376
377         if (node instanceof ActionNodeContainer) {
378             ((ActionNodeContainer) node).getActions().forEach(actionDef -> {
379                 final String resolvedPath = "rests/operations" + resourcePath.substring(11)
380                         + "/" + resolvePathArgumentsName(actionDef.getQName(), node.getQName(), schemaContext);
381                 addOperations(actionDef, moduleName, deviceName, paths, parentName, definitionNames, resolvedPath);
382             });
383         }
384
385         for (final DataSchemaNode childNode : childSchemaNodes) {
386             if (childNode instanceof ListSchemaNode || childNode instanceof ContainerSchemaNode) {
387                 final String newParent = parentName + "_" + node.getQName().getLocalName();
388                 final String localName = resolvePathArgumentsName(childNode.getQName(), node.getQName(), schemaContext);
389                 final String newResourcePath = resourcePath + "/" + createPath(childNode, pathParams, localName);
390                 final boolean newIsConfig = isConfig && childNode.isConfiguration();
391                 addPaths(childNode, deviceName, moduleName, paths, pathParams, schemaContext,
392                     newIsConfig, newParent, definitionNames, newResourcePath);
393             }
394         }
395     }
396
397     private static boolean containsListOrContainer(final Iterable<? extends DataSchemaNode> nodes) {
398         for (final DataSchemaNode child : nodes) {
399             if (child instanceof ListSchemaNode || child instanceof ContainerSchemaNode) {
400                 return true;
401             }
402         }
403         return false;
404     }
405
406     private static Map<String, ObjectNode> operations(final DataSchemaNode node, final String moduleName,
407             final Optional<String> deviceName, final ArrayNode pathParams, final boolean isConfig,
408             final String parentName, final DefinitionNames definitionNames) {
409         final Map<String, ObjectNode> operations = new HashMap<>();
410         final String discriminator = definitionNames.getDiscriminator(node);
411
412         final String nodeName = node.getQName().getLocalName();
413
414         final String defName = parentName + "_" + nodeName + TOP + discriminator;
415         final ObjectNode get = buildGet(node, moduleName, deviceName, pathParams, defName, isConfig);
416         operations.put("get", get);
417
418
419         if (isConfig) {
420             final ObjectNode put = buildPut(parentName, nodeName, discriminator, moduleName, deviceName,
421                     node.getDescription().orElse(""), pathParams);
422             operations.put("put", put);
423
424             final ObjectNode patch = buildPatch(parentName, nodeName, moduleName, deviceName,
425                     node.getDescription().orElse(""), pathParams);
426             operations.put("patch", patch);
427
428             final ObjectNode delete = buildDelete(node, moduleName, deviceName, pathParams);
429             operations.put("delete", delete);
430
431             operations.put("post", buildPost(parentName, nodeName, discriminator, moduleName, deviceName,
432                     node.getDescription().orElse(""), pathParams));
433         }
434         return operations;
435     }
436
437     protected abstract ListPathBuilder newListPathBuilder();
438
439     private String createPath(final DataSchemaNode schemaNode, final ArrayNode pathParams, final String localName) {
440         final StringBuilder path = new StringBuilder();
441         path.append(localName);
442
443         if (schemaNode instanceof ListSchemaNode) {
444             final ListPathBuilder keyBuilder = newListPathBuilder();
445             for (final QName listKey : ((ListSchemaNode) schemaNode).getKeyDefinition()) {
446                 final String paramName = createUniquePathParamName(listKey.getLocalName(), pathParams);
447                 final String pathParamIdentifier = keyBuilder.nextParamIdentifier(paramName);
448
449                 path.append(pathParamIdentifier);
450
451                 final ObjectNode pathParam = JsonNodeFactory.instance.objectNode();
452                 pathParam.put("name", paramName);
453
454                 ((DataNodeContainer) schemaNode).findDataChildByName(listKey).flatMap(DataSchemaNode::getDescription)
455                         .ifPresent(desc -> pathParam.put("description", desc));
456
457                 final ObjectNode typeParent = getTypeParentNode(pathParam);
458
459                 typeParent.put("type", "string");
460                 pathParam.put("in", "path");
461                 pathParam.put("required", true);
462
463                 pathParams.add(pathParam);
464             }
465         }
466         return path.toString();
467     }
468
469     private String createUniquePathParamName(final String clearName, final ArrayNode pathParams) {
470         for (final JsonNode pathParam : pathParams) {
471             if (isNamePicked(clearName, pathParam)) {
472                 return createUniquePathParamName(clearName, pathParams, 1);
473             }
474         }
475         return clearName;
476     }
477
478     private String createUniquePathParamName(final String clearName, final ArrayNode pathParams,
479             final int discriminator) {
480         final String newName = clearName + discriminator;
481         for (final JsonNode pathParam : pathParams) {
482             if (isNamePicked(newName, pathParam)) {
483                 return createUniquePathParamName(clearName, pathParams, discriminator + 1);
484             }
485         }
486         return newName;
487     }
488
489     private static boolean isNamePicked(final String name, final JsonNode pathParam) {
490         return name.equals(pathParam.get("name").asText());
491     }
492
493     public SortedSet<Module> getSortedModules(final EffectiveModelContext schemaContext) {
494         if (schemaContext == null) {
495             return Collections.emptySortedSet();
496         }
497
498         final SortedSet<Module> sortedModules = new TreeSet<>((module1, module2) -> {
499             int result = module1.getName().compareTo(module2.getName());
500             if (result == 0) {
501                 result = Revision.compare(module1.getRevision(), module2.getRevision());
502             }
503             if (result == 0) {
504                 result = module1.getNamespace().compareTo(module2.getNamespace());
505             }
506             return result;
507         });
508         for (final Module m : schemaContext.getModules()) {
509             if (m != null) {
510                 sortedModules.add(m);
511             }
512         }
513         return sortedModules;
514     }
515
516     private static void addOperations(final OperationDefinition operDef, final String moduleName,
517             final Optional<String> deviceName, final ObjectNode paths, final String parentName,
518             final DefinitionNames definitionNames, final String resourcePath) {
519         final ObjectNode operations = JsonNodeFactory.instance.objectNode();
520         operations.set("post", buildPostOperation(operDef, moduleName, deviceName, parentName, definitionNames));
521         paths.set(resourcePath, operations);
522     }
523
524     protected abstract void appendPathKeyValue(StringBuilder builder, Object value);
525
526     public String generateUrlPrefixFromInstanceID(final YangInstanceIdentifier key, final String moduleName) {
527         final StringBuilder builder = new StringBuilder();
528         builder.append("/");
529         if (moduleName != null) {
530             builder.append(moduleName).append(':');
531         }
532         for (final PathArgument arg : key.getPathArguments()) {
533             final String name = arg.getNodeType().getLocalName();
534             if (arg instanceof NodeIdentifierWithPredicates nodeId) {
535                 for (final Entry<QName, Object> entry : nodeId.entrySet()) {
536                     appendPathKeyValue(builder, entry.getValue());
537                 }
538             } else {
539                 builder.append(name).append('/');
540             }
541         }
542         return builder.toString();
543     }
544
545     protected interface ListPathBuilder {
546         String nextParamIdentifier(String key);
547     }
548 }