Improve ApiPathNormalizer.normalizeSubResource()
[netconf.git] / restconf / restconf-nb / src / main / java / org / opendaylight / restconf / server / spi / ApiPathNormalizer.java
1 /*
2  * Copyright (c) 2016 Cisco 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.restconf.server.spi;
9
10 import static com.google.common.base.Verify.verify;
11 import static com.google.common.base.Verify.verifyNotNull;
12 import static java.util.Objects.requireNonNull;
13
14 import com.google.common.base.VerifyException;
15 import com.google.common.collect.ImmutableList;
16 import com.google.common.collect.ImmutableMap;
17 import java.io.IOException;
18 import java.util.ArrayList;
19 import java.util.Iterator;
20 import java.util.List;
21 import org.eclipse.jdt.annotation.NonNull;
22 import org.opendaylight.restconf.api.ApiPath;
23 import org.opendaylight.restconf.api.ApiPath.ApiIdentifier;
24 import org.opendaylight.restconf.api.ApiPath.ListInstance;
25 import org.opendaylight.restconf.api.ApiPath.Step;
26 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
27 import org.opendaylight.restconf.nb.rfc8040.Insert.PointNormalizer;
28 import org.opendaylight.restconf.server.api.DatabindContext;
29 import org.opendaylight.restconf.server.api.DatabindPath;
30 import org.opendaylight.restconf.server.api.DatabindPath.Action;
31 import org.opendaylight.restconf.server.api.DatabindPath.Data;
32 import org.opendaylight.restconf.server.api.DatabindPath.InstanceReference;
33 import org.opendaylight.restconf.server.api.DatabindPath.Rpc;
34 import org.opendaylight.yangtools.yang.common.ErrorTag;
35 import org.opendaylight.yangtools.yang.common.ErrorType;
36 import org.opendaylight.yangtools.yang.common.QName;
37 import org.opendaylight.yangtools.yang.common.QNameModule;
38 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
39 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
40 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
41 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
42 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
43 import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodec;
44 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext;
45 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.Composite;
46 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.PathMixin;
47 import org.opendaylight.yangtools.yang.model.api.ActionNodeContainer;
48 import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
49 import org.opendaylight.yangtools.yang.model.api.LeafListSchemaNode;
50 import org.opendaylight.yangtools.yang.model.api.LeafSchemaNode;
51 import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
52 import org.opendaylight.yangtools.yang.model.api.TypedDataSchemaNode;
53 import org.opendaylight.yangtools.yang.model.api.stmt.RpcEffectiveStatement;
54 import org.opendaylight.yangtools.yang.model.api.stmt.SchemaTreeEffectiveStatement;
55 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
56
57 /**
58  * Utility for normalizing {@link ApiPath}s. An {@link ApiPath} can represent a number of different constructs, as
59  * denoted to in the {@link DatabindPath} interface hierarchy.
60  *
61  * <p>
62  * This process is governed by
63  * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.5.3">RFC8040, section 3.5.3</a>. The URI provides the
64  * equivalent of NETCONF XML filter encoding, with data values being escaped RFC7891 strings.
65  */
66 public final class ApiPathNormalizer implements PointNormalizer {
67     private final @NonNull DatabindContext databind;
68
69     public ApiPathNormalizer(final DatabindContext databind) {
70         this.databind = requireNonNull(databind);
71     }
72
73     public @NonNull DatabindPath normalizePath(final ApiPath apiPath) {
74         final var it = apiPath.steps().iterator();
75         if (!it.hasNext()) {
76             return new Data(databind);
77         }
78
79         // First step is somewhat special:
80         // - it has to contain a module qualifier
81         // - it has to consider RPCs, for which we need SchemaContext
82         //
83         // We therefore peel that first iteration here and not worry about those details in further iterations
84         final var firstStep = it.next();
85         final var firstModule = firstStep.module();
86         if (firstModule == null) {
87             throw new RestconfDocumentedException(
88                 "First member must use namespace-qualified form, '" + firstStep.identifier() + "' does not",
89                 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
90         }
91
92         var namespace = resolveNamespace(firstModule);
93         var step = firstStep;
94         var qname = step.identifier().bindTo(namespace);
95
96         // We go through more modern APIs here to get this special out of the way quickly
97         final var modelContext = databind.modelContext();
98         final var optRpc = modelContext.findModuleStatement(namespace).orElseThrow()
99             .findSchemaTreeNode(RpcEffectiveStatement.class, qname);
100         if (optRpc.isPresent()) {
101             final var rpc = optRpc.orElseThrow();
102
103             // We have found an RPC match,
104             if (it.hasNext()) {
105                 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
106                     + "therefore it must be the only step present", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
107             }
108             if (step instanceof ListInstance) {
109                 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
110                     + "therefore it must not contain key values", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
111             }
112
113             final var stack = SchemaInferenceStack.of(modelContext);
114             final var stmt = stack.enterSchemaTree(rpc.argument());
115             verify(rpc.equals(stmt), "Expecting %s, inferred %s", rpc, stmt);
116             return new Rpc(databind, stack.toInference(), rpc);
117         }
118
119         return normalizeSteps(SchemaInferenceStack.of(modelContext), databind.schemaTree().getRoot(), List.of(),
120             namespace, firstStep, it);
121     }
122
123     private @NonNull DatabindPath normalizeSteps(final SchemaInferenceStack stack,
124             final @NonNull DataSchemaContext rootNode, final @NonNull List<PathArgument> pathPrefix,
125             final @NonNull QNameModule firstNamespace, final @NonNull Step firstStep,
126             final Iterator<@NonNull Step> it) {
127         var parentNode = rootNode;
128         var namespace = firstNamespace;
129         var step = firstStep;
130         var qname = step.identifier().bindTo(namespace);
131
132         final var path = new ArrayList<>(pathPrefix);
133         while (true) {
134             final var parentSchema = parentNode.dataSchemaNode();
135             if (parentSchema instanceof ActionNodeContainer actionParent) {
136                 final var optAction = actionParent.findAction(qname);
137                 if (optAction.isPresent()) {
138                     final var action = optAction.orElseThrow();
139
140                     if (it.hasNext()) {
141                         throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
142                             + "therefore it must not continue past it", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
143                     }
144                     if (step instanceof ListInstance) {
145                         throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
146                             + "therefore it must not contain key values",
147                             ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
148                     }
149
150                     final var stmt = stack.enterSchemaTree(qname);
151                     final var actionStmt = action.asEffectiveStatement();
152                     verify(actionStmt.equals(stmt), "Expecting %s, inferred %s", actionStmt, stmt);
153
154                     return new Action(databind, stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
155                 }
156             }
157
158             // Resolve the child step with respect to data schema tree
159             final var found = parentNode instanceof DataSchemaContext.Composite composite
160                 ? composite.enterChild(stack, qname) : null;
161             if (found == null) {
162                 throw new RestconfDocumentedException("Schema for '" + qname + "' not found",
163                     ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
164             }
165
166             // Now add all mixins encountered to the path
167             var childNode = found;
168             while (childNode instanceof PathMixin currentMixin) {
169                 path.add(currentMixin.mixinPathStep());
170                 childNode = verifyNotNull(currentMixin.enterChild(stack, qname),
171                     "Mixin %s is missing child for %s while resolving %s", childNode, qname, found);
172             }
173
174             final PathArgument pathArg;
175             if (step instanceof ListInstance listStep) {
176                 final var values = listStep.keyValues();
177                 final var schema = childNode.dataSchemaNode();
178                 if (schema instanceof ListSchemaNode listSchema) {
179                     pathArg = prepareNodeWithPredicates(stack, qname, listSchema, values);
180                 } else if (schema instanceof LeafListSchemaNode leafListSchema) {
181                     if (values.size() != 1) {
182                         throw new RestconfDocumentedException("Entry '" + qname + "' requires one value predicate.",
183                             ErrorType.PROTOCOL, ErrorTag.BAD_ATTRIBUTE);
184                     }
185                     pathArg = new NodeWithValue<>(qname, parserJsonValue(stack, leafListSchema, values.get(0)));
186                 } else {
187                     throw new RestconfDocumentedException(
188                         "Entry '" + qname + "' does not take a key or value predicate.",
189                         ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
190                 }
191             } else {
192                 if (childNode.dataSchemaNode() instanceof ListSchemaNode list && !list.getKeyDefinition().isEmpty()) {
193                     throw new RestconfDocumentedException(
194                         "Entry '" + qname + "' requires key or value predicate to be present.",
195                         ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
196                 }
197                 pathArg = childNode.getPathStep();
198             }
199
200             path.add(pathArg);
201
202             if (!it.hasNext()) {
203                 return new Data(databind, stack.toInference(), YangInstanceIdentifier.of(path), childNode);
204             }
205
206             parentNode = childNode;
207             step = it.next();
208             final var module = step.module();
209             if (module != null) {
210                 namespace = resolveNamespace(module);
211             }
212
213             qname = step.identifier().bindTo(namespace);
214         }
215     }
216
217     public @NonNull Data normalizeDataPath(final ApiPath apiPath) {
218         final var path = normalizePath(apiPath);
219         if (path instanceof Data dataPath) {
220             return dataPath;
221         }
222         throw new RestconfDocumentedException("Point '" + apiPath + "' resolves to non-data " + path,
223             ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
224     }
225
226     public static @NonNull Data normalizeSubResource(final Data resource, final ApiPath subResource) {
227         // If subResource is empty just return the resource
228         final var steps = subResource.steps();
229         if (steps.isEmpty()) {
230             return requireNonNull(resource);
231         }
232
233         final var normalizer = new ApiPathNormalizer(resource.databind());
234         final var urlPath = resource.instance();
235         if (urlPath.isEmpty()) {
236             // URL indicates the datastore resource, let's just normalize targetPath
237             return normalizer.normalizeDataPath(subResource);
238         }
239
240         // Defer to normalizePath(), faking things a bit. Then check the result.
241         final var it = steps.iterator();
242         final var path = normalizer.normalizeSteps(resource.inference().toSchemaInferenceStack(), resource.schema(),
243             urlPath.getPathArguments(), urlPath.getLastPathArgument().getNodeType().getModule(), it.next(), it);
244         if (path instanceof Data dataPath) {
245             return dataPath;
246         }
247         throw new RestconfDocumentedException("Sub-resource '" + subResource + "' resolves to non-data " + path,
248             ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
249     }
250
251     @Override
252     public PathArgument normalizePoint(final ApiPath value) {
253         final var path = normalizePath(value);
254         if (path instanceof Data dataPath) {
255             final var lastArg = dataPath.instance().getLastPathArgument();
256             if (lastArg != null) {
257                 return lastArg;
258             }
259             throw new IllegalArgumentException("Point '" + value + "' resolves to an empty path");
260         }
261         throw new IllegalArgumentException("Point '" + value + "' resolves to non-data " + path);
262     }
263
264     public @NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
265         final var steps = apiPath.steps();
266         return switch (steps.size()) {
267             case 0 -> throw new RestconfDocumentedException("RPC name must be present", ErrorType.PROTOCOL,
268                 ErrorTag.DATA_MISSING);
269             case 1 -> normalizeRpcPath(steps.get(0));
270             default -> throw new RestconfDocumentedException(apiPath + " does not refer to an RPC", ErrorType.PROTOCOL,
271                 ErrorTag.DATA_MISSING);
272         };
273     }
274
275     public @NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
276         final var firstModule = step.module();
277         if (firstModule == null) {
278             throw new RestconfDocumentedException(
279                 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
280                 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
281         }
282
283         final var namespace = resolveNamespace(firstModule);
284         final var qname = step.identifier().bindTo(namespace);
285         final var stack = SchemaInferenceStack.of(databind.modelContext());
286         final SchemaTreeEffectiveStatement<?> stmt;
287         try {
288             stmt = stack.enterSchemaTree(qname);
289         } catch (IllegalArgumentException e) {
290             throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
291                 ErrorTag.DATA_MISSING, e);
292         }
293         if (stmt instanceof RpcEffectiveStatement rpc) {
294             return new Rpc(databind, stack.toInference(), rpc);
295         }
296         throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
297             ErrorTag.DATA_MISSING);
298     }
299
300     public @NonNull InstanceReference normalizeDataOrActionPath(final ApiPath apiPath) {
301         // FIXME: optimize this
302         final var path = normalizePath(apiPath);
303         if (path instanceof Data dataPath) {
304             return dataPath;
305         }
306         if (path instanceof Action actionPath) {
307             return actionPath;
308         }
309         throw new RestconfDocumentedException("Unexpected path " + path, ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
310     }
311
312     /**
313      * Return the canonical {@link ApiPath} for specified {@link YangInstanceIdentifier}.
314      *
315      * @param path {@link YangInstanceIdentifier} to canonicalize
316      * @return An {@link ApiPath}
317      */
318     public @NonNull ApiPath canonicalize(final YangInstanceIdentifier path) {
319         final var it = path.getPathArguments().iterator();
320         if (!it.hasNext()) {
321             return ApiPath.empty();
322         }
323
324         final var stack = SchemaInferenceStack.of(databind.modelContext());
325         final var builder = ImmutableList.<Step>builder();
326         DataSchemaContext context = databind.schemaTree().getRoot();
327         QNameModule parentModule = null;
328         do {
329             final var arg = it.next();
330
331             // get module of the parent
332             if (!(context instanceof PathMixin)) {
333                 parentModule = context.dataSchemaNode().getQName().getModule();
334             }
335
336             final var childContext = context instanceof Composite composite ? composite.enterChild(stack, arg) : null;
337             if (childContext == null) {
338                 throw new RestconfDocumentedException(
339                     "Invalid input '%s': schema for argument '%s' (after '%s') not found".formatted(path, arg,
340                         ApiPath.of(builder.build())), ErrorType.APPLICATION, ErrorTag.UNKNOWN_ELEMENT);
341             }
342
343             context = childContext;
344             if (childContext instanceof PathMixin) {
345                 // This PathArgument is a mixed-in YangInstanceIdentifier, do not emit anything and continue
346                 continue;
347             }
348
349             builder.add(canonicalize(arg, parentModule, stack, context));
350         } while (it.hasNext());
351
352         return new ApiPath(builder.build());
353     }
354
355     private @NonNull Step canonicalize(final PathArgument arg, final QNameModule prevNamespace,
356             final SchemaInferenceStack stack, final DataSchemaContext context) {
357         // append namespace before every node which is defined in other module than its parent
358         // condition is satisfied also for the first path argument
359         final var nodeType = arg.getNodeType();
360         final var module = nodeType.getModule().equals(prevNamespace) ? null : resolvePrefix(nodeType);
361         final var identifier = nodeType.unbind();
362
363         // NodeIdentifier maps to an ApiIdentifier
364         if (arg instanceof NodeIdentifier) {
365             return new ApiIdentifier(module, identifier);
366         }
367
368         // NodeWithValue addresses a LeafSetEntryNode and maps to a ListInstance with a single value
369         final var schema = context.dataSchemaNode();
370         if (arg instanceof NodeWithValue<?> withValue) {
371             if (!(schema instanceof LeafListSchemaNode leafList)) {
372                 throw new RestconfDocumentedException(
373                     "Argument '%s' does not map to a leaf-list, but %s".formatted(arg, schema),
374                     ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
375             }
376             return ListInstance.of(module, identifier, encodeValue(stack, leafList, withValue.getValue()));
377         }
378
379         // The only remaining case is NodeIdentifierWrithPredicates, verify that instead of an explicit cast
380         if (!(arg instanceof NodeIdentifierWithPredicates withPredicates)) {
381             throw new VerifyException("Unhandled " + arg);
382         }
383         // A NodeIdentifierWithPredicates adresses a MapEntryNode and maps to a ListInstance with one or more values:
384         // 1) schema has to be a ListSchemaNode
385         if (!(schema instanceof ListSchemaNode list)) {
386             throw new RestconfDocumentedException(
387                 "Argument '%s' does not map to a list, but %s".formatted(arg, schema),
388                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
389         }
390         // 2) the key definition must be non-empty
391         final var keyDef = list.getKeyDefinition();
392         final var size = keyDef.size();
393         if (size == 0) {
394             throw new RestconfDocumentedException(
395                 "Argument '%s' maps a list without any keys %s".formatted(arg, schema),
396                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
397         }
398         // 3) the number of predicates has to match the number of keys
399         if (size != withPredicates.size()) {
400             throw new RestconfDocumentedException(
401                 "Argument '%s' does not match required keys %s".formatted(arg, keyDef),
402                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
403         }
404
405         // ListSchemaNode implies the context is a composite, verify that instead of an unexplained cast when we look
406         // up the schema for individual keys
407         if (!(context instanceof Composite composite)) {
408             throw new VerifyException("Unexpected non-composite " + context + " with " + list);
409         }
410
411         final var builder = ImmutableList.<String>builderWithExpectedSize(size);
412         for (var key : keyDef) {
413             final var value = withPredicates.getValue(key);
414             if (value == null) {
415                 throw new RestconfDocumentedException("Argument '%s' is missing predicate for %s".formatted(arg, key),
416                     ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
417             }
418
419             final var tmpStack = stack.copy();
420             final var keyContext = composite.enterChild(tmpStack, key);
421             if (keyContext == null) {
422                 throw new VerifyException("Failed to find key " + key + " in " + composite);
423             }
424             if (!(keyContext.dataSchemaNode() instanceof LeafSchemaNode leaf)) {
425                 throw new VerifyException("Key " + key + " maps to non-leaf context " + keyContext);
426             }
427             builder.add(encodeValue(tmpStack, leaf, value));
428         }
429         return ListInstance.of(module, identifier, builder.build());
430     }
431
432     private String encodeValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schema, final Object value) {
433         @SuppressWarnings("unchecked")
434         final var codec = (JSONCodec<Object>) databind.jsonCodecs().codecFor(schema, stack);
435         try (var jsonWriter = new HackJsonWriter()) {
436             codec.writeValue(jsonWriter, value);
437             return jsonWriter.acquireCaptured().rawString();
438         } catch (IOException e) {
439             throw new IllegalStateException("Failed to serialize '" + value + "'", e);
440         }
441     }
442
443     private NodeIdentifierWithPredicates prepareNodeWithPredicates(final SchemaInferenceStack stack, final QName qname,
444             final @NonNull ListSchemaNode schema, final List<@NonNull String> keyValues) {
445         final var keyDef = schema.getKeyDefinition();
446         final var keySize = keyDef.size();
447         final var varSize = keyValues.size();
448         if (keySize != varSize) {
449             throw new RestconfDocumentedException(
450                 "Schema for " + qname + " requires " + keySize + " key values, " + varSize + " supplied",
451                 ErrorType.PROTOCOL, keySize > varSize ? ErrorTag.MISSING_ATTRIBUTE : ErrorTag.UNKNOWN_ATTRIBUTE);
452         }
453
454         final var values = ImmutableMap.<QName, Object>builderWithExpectedSize(keySize);
455         final var tmp = stack.copy();
456         for (int i = 0; i < keySize; ++i) {
457             final QName keyName = keyDef.get(i);
458             final var child = schema.getDataChildByName(keyName);
459             tmp.enterSchemaTree(keyName);
460             values.put(keyName, prepareValueByType(tmp, child, keyValues.get(i)));
461             tmp.exit();
462         }
463
464         return NodeIdentifierWithPredicates.of(qname, values.build());
465     }
466
467     private Object prepareValueByType(final SchemaInferenceStack stack, final DataSchemaNode schemaNode,
468             final @NonNull String value) {
469         if (schemaNode instanceof TypedDataSchemaNode typedSchema) {
470             return parserJsonValue(stack, typedSchema, value);
471         }
472         throw new VerifyException("Unhandled schema " + schemaNode + " decoding '" + value + "'");
473     }
474
475     private Object parserJsonValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
476             final String value) {
477         // As per https://www.rfc-editor.org/rfc/rfc8040#page-29:
478         //            The syntax for
479         //            "api-identifier" and "key-value" MUST conform to the JSON identifier
480         //            encoding rules in Section 4 of [RFC7951]: The RESTCONF root resource
481         //            path is required.  Additional sub-resource identifiers are optional.
482         //            The characters in a key value string are constrained, and some
483         //            characters need to be percent-encoded, as described in Section 3.5.3.
484         try {
485             return databind.jsonCodecs().codecFor(schemaNode, stack).parseValue(null, value);
486         } catch (IllegalArgumentException e) {
487             throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
488                 ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
489         }
490     }
491
492     private @NonNull QNameModule resolveNamespace(final String moduleName) {
493         final var it = databind.modelContext().findModuleStatements(moduleName).iterator();
494         if (it.hasNext()) {
495             return it.next().localQNameModule();
496         }
497         throw new RestconfDocumentedException("Failed to lookup for module with name '" + moduleName + "'.",
498             ErrorType.PROTOCOL, ErrorTag.UNKNOWN_ELEMENT);
499     }
500
501     /**
502      * Create prefix of namespace from {@link QName}.
503      *
504      * @param qname {@link QName}
505      * @return {@link String}
506      */
507     private @NonNull String resolvePrefix(final QName qname) {
508         return databind.modelContext().findModuleStatement(qname.getModule()).orElseThrow().argument().getLocalName();
509     }
510 }