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