Split out ApiPathCanonizer
[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.ImmutableMap;
16 import java.util.ArrayList;
17 import java.util.Iterator;
18 import java.util.List;
19 import org.eclipse.jdt.annotation.NonNull;
20 import org.opendaylight.restconf.api.ApiPath;
21 import org.opendaylight.restconf.api.ApiPath.ListInstance;
22 import org.opendaylight.restconf.api.ApiPath.Step;
23 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
24 import org.opendaylight.restconf.nb.rfc8040.Insert.PointNormalizer;
25 import org.opendaylight.restconf.server.api.DatabindContext;
26 import org.opendaylight.restconf.server.api.DatabindPath;
27 import org.opendaylight.restconf.server.api.DatabindPath.Action;
28 import org.opendaylight.restconf.server.api.DatabindPath.Data;
29 import org.opendaylight.restconf.server.api.DatabindPath.InstanceReference;
30 import org.opendaylight.restconf.server.api.DatabindPath.Rpc;
31 import org.opendaylight.yangtools.yang.common.ErrorTag;
32 import org.opendaylight.yangtools.yang.common.ErrorType;
33 import org.opendaylight.yangtools.yang.common.QName;
34 import org.opendaylight.yangtools.yang.common.QNameModule;
35 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
36 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
37 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
38 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
39 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext;
40 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.PathMixin;
41 import org.opendaylight.yangtools.yang.model.api.ActionNodeContainer;
42 import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
43 import org.opendaylight.yangtools.yang.model.api.LeafListSchemaNode;
44 import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
45 import org.opendaylight.yangtools.yang.model.api.TypedDataSchemaNode;
46 import org.opendaylight.yangtools.yang.model.api.stmt.RpcEffectiveStatement;
47 import org.opendaylight.yangtools.yang.model.api.stmt.SchemaTreeEffectiveStatement;
48 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
49
50 /**
51  * Utility for normalizing {@link ApiPath}s. An {@link ApiPath} can represent a number of different constructs, as
52  * denoted to in the {@link DatabindPath} interface hierarchy.
53  *
54  * <p>
55  * This process is governed by
56  * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.5.3">RFC8040, section 3.5.3</a>. The URI provides the
57  * equivalent of NETCONF XML filter encoding, with data values being escaped RFC7891 strings.
58  */
59 public final class ApiPathNormalizer implements PointNormalizer {
60     private final @NonNull DatabindContext databind;
61
62     public ApiPathNormalizer(final DatabindContext databind) {
63         this.databind = requireNonNull(databind);
64     }
65
66     public @NonNull DatabindPath normalizePath(final ApiPath apiPath) {
67         final var it = apiPath.steps().iterator();
68         if (!it.hasNext()) {
69             return new Data(databind);
70         }
71
72         // First step is somewhat special:
73         // - it has to contain a module qualifier
74         // - it has to consider RPCs, for which we need SchemaContext
75         //
76         // We therefore peel that first iteration here and not worry about those details in further iterations
77         final var firstStep = it.next();
78         final var firstModule = firstStep.module();
79         if (firstModule == null) {
80             throw new RestconfDocumentedException(
81                 "First member must use namespace-qualified form, '" + firstStep.identifier() + "' does not",
82                 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
83         }
84
85         var namespace = resolveNamespace(firstModule);
86         var step = firstStep;
87         var qname = step.identifier().bindTo(namespace);
88
89         // We go through more modern APIs here to get this special out of the way quickly
90         final var modelContext = databind.modelContext();
91         final var optRpc = modelContext.findModuleStatement(namespace).orElseThrow()
92             .findSchemaTreeNode(RpcEffectiveStatement.class, qname);
93         if (optRpc.isPresent()) {
94             final var rpc = optRpc.orElseThrow();
95
96             // We have found an RPC match,
97             if (it.hasNext()) {
98                 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
99                     + "therefore it must be the only step present", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
100             }
101             if (step instanceof ListInstance) {
102                 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
103                     + "therefore it must not contain key values", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
104             }
105
106             final var stack = SchemaInferenceStack.of(modelContext);
107             final var stmt = stack.enterSchemaTree(rpc.argument());
108             verify(rpc.equals(stmt), "Expecting %s, inferred %s", rpc, stmt);
109             return new Rpc(databind, stack.toInference(), rpc);
110         }
111
112         return normalizeSteps(SchemaInferenceStack.of(modelContext), databind.schemaTree().getRoot(), List.of(),
113             namespace, firstStep, it);
114     }
115
116     private @NonNull DatabindPath normalizeSteps(final SchemaInferenceStack stack,
117             final @NonNull DataSchemaContext rootNode, final @NonNull List<PathArgument> pathPrefix,
118             final @NonNull QNameModule firstNamespace, final @NonNull Step firstStep,
119             final Iterator<@NonNull Step> it) {
120         var parentNode = rootNode;
121         var namespace = firstNamespace;
122         var step = firstStep;
123         var qname = step.identifier().bindTo(namespace);
124
125         final var path = new ArrayList<>(pathPrefix);
126         while (true) {
127             final var parentSchema = parentNode.dataSchemaNode();
128             if (parentSchema instanceof ActionNodeContainer actionParent) {
129                 final var optAction = actionParent.findAction(qname);
130                 if (optAction.isPresent()) {
131                     final var action = optAction.orElseThrow();
132
133                     if (it.hasNext()) {
134                         throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
135                             + "therefore it must not continue past it", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
136                     }
137                     if (step instanceof ListInstance) {
138                         throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
139                             + "therefore it must not contain key values",
140                             ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
141                     }
142
143                     final var stmt = stack.enterSchemaTree(qname);
144                     final var actionStmt = action.asEffectiveStatement();
145                     verify(actionStmt.equals(stmt), "Expecting %s, inferred %s", actionStmt, stmt);
146
147                     return new Action(databind, stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
148                 }
149             }
150
151             // Resolve the child step with respect to data schema tree
152             final var found = parentNode instanceof DataSchemaContext.Composite composite
153                 ? composite.enterChild(stack, qname) : null;
154             if (found == null) {
155                 throw new RestconfDocumentedException("Schema for '" + qname + "' not found",
156                     ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
157             }
158
159             // Now add all mixins encountered to the path
160             var childNode = found;
161             while (childNode instanceof PathMixin currentMixin) {
162                 path.add(currentMixin.mixinPathStep());
163                 childNode = verifyNotNull(currentMixin.enterChild(stack, qname),
164                     "Mixin %s is missing child for %s while resolving %s", childNode, qname, found);
165             }
166
167             final PathArgument pathArg;
168             if (step instanceof ListInstance listStep) {
169                 final var values = listStep.keyValues();
170                 final var schema = childNode.dataSchemaNode();
171                 if (schema instanceof ListSchemaNode listSchema) {
172                     pathArg = prepareNodeWithPredicates(stack, qname, listSchema, values);
173                 } else if (schema instanceof LeafListSchemaNode leafListSchema) {
174                     if (values.size() != 1) {
175                         throw new RestconfDocumentedException("Entry '" + qname + "' requires one value predicate.",
176                             ErrorType.PROTOCOL, ErrorTag.BAD_ATTRIBUTE);
177                     }
178                     pathArg = new NodeWithValue<>(qname, parserJsonValue(stack, leafListSchema, values.get(0)));
179                 } else {
180                     throw new RestconfDocumentedException(
181                         "Entry '" + qname + "' does not take a key or value predicate.",
182                         ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
183                 }
184             } else {
185                 if (childNode.dataSchemaNode() instanceof ListSchemaNode list && !list.getKeyDefinition().isEmpty()) {
186                     throw new RestconfDocumentedException(
187                         "Entry '" + qname + "' requires key or value predicate to be present.",
188                         ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
189                 }
190                 pathArg = childNode.getPathStep();
191             }
192
193             path.add(pathArg);
194
195             if (!it.hasNext()) {
196                 return new Data(databind, stack.toInference(), YangInstanceIdentifier.of(path), childNode);
197             }
198
199             parentNode = childNode;
200             step = it.next();
201             final var module = step.module();
202             if (module != null) {
203                 namespace = resolveNamespace(module);
204             }
205
206             qname = step.identifier().bindTo(namespace);
207         }
208     }
209
210     public @NonNull Data normalizeDataPath(final ApiPath apiPath) {
211         final var path = normalizePath(apiPath);
212         if (path instanceof Data dataPath) {
213             return dataPath;
214         }
215         throw new RestconfDocumentedException("Point '" + apiPath + "' resolves to non-data " + path,
216             ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
217     }
218
219     public static @NonNull Data normalizeSubResource(final Data resource, final ApiPath subResource) {
220         // If subResource is empty just return the resource
221         final var steps = subResource.steps();
222         if (steps.isEmpty()) {
223             return requireNonNull(resource);
224         }
225
226         final var normalizer = new ApiPathNormalizer(resource.databind());
227         final var urlPath = resource.instance();
228         if (urlPath.isEmpty()) {
229             // URL indicates the datastore resource, let's just normalize targetPath
230             return normalizer.normalizeDataPath(subResource);
231         }
232
233         // Defer to normalizePath(), faking things a bit. Then check the result.
234         final var it = steps.iterator();
235         final var path = normalizer.normalizeSteps(resource.inference().toSchemaInferenceStack(), resource.schema(),
236             urlPath.getPathArguments(), urlPath.getLastPathArgument().getNodeType().getModule(), it.next(), it);
237         if (path instanceof Data dataPath) {
238             return dataPath;
239         }
240         throw new RestconfDocumentedException("Sub-resource '" + subResource + "' resolves to non-data " + path,
241             ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
242     }
243
244     @Override
245     public PathArgument normalizePoint(final ApiPath value) {
246         final var path = normalizePath(value);
247         if (path instanceof Data dataPath) {
248             final var lastArg = dataPath.instance().getLastPathArgument();
249             if (lastArg != null) {
250                 return lastArg;
251             }
252             throw new IllegalArgumentException("Point '" + value + "' resolves to an empty path");
253         }
254         throw new IllegalArgumentException("Point '" + value + "' resolves to non-data " + path);
255     }
256
257     public @NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
258         final var steps = apiPath.steps();
259         return switch (steps.size()) {
260             case 0 -> throw new RestconfDocumentedException("RPC name must be present", ErrorType.PROTOCOL,
261                 ErrorTag.DATA_MISSING);
262             case 1 -> normalizeRpcPath(steps.get(0));
263             default -> throw new RestconfDocumentedException(apiPath + " does not refer to an RPC", ErrorType.PROTOCOL,
264                 ErrorTag.DATA_MISSING);
265         };
266     }
267
268     public @NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
269         final var firstModule = step.module();
270         if (firstModule == null) {
271             throw new RestconfDocumentedException(
272                 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
273                 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
274         }
275
276         final var namespace = resolveNamespace(firstModule);
277         final var qname = step.identifier().bindTo(namespace);
278         final var stack = SchemaInferenceStack.of(databind.modelContext());
279         final SchemaTreeEffectiveStatement<?> stmt;
280         try {
281             stmt = stack.enterSchemaTree(qname);
282         } catch (IllegalArgumentException e) {
283             throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
284                 ErrorTag.DATA_MISSING, e);
285         }
286         if (stmt instanceof RpcEffectiveStatement rpc) {
287             return new Rpc(databind, stack.toInference(), rpc);
288         }
289         throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
290             ErrorTag.DATA_MISSING);
291     }
292
293     public @NonNull InstanceReference normalizeDataOrActionPath(final ApiPath apiPath) {
294         // FIXME: optimize this
295         final var path = normalizePath(apiPath);
296         if (path instanceof Data dataPath) {
297             return dataPath;
298         }
299         if (path instanceof Action actionPath) {
300             return actionPath;
301         }
302         throw new RestconfDocumentedException("Unexpected path " + path, ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
303     }
304
305     private NodeIdentifierWithPredicates prepareNodeWithPredicates(final SchemaInferenceStack stack, final QName qname,
306             final @NonNull ListSchemaNode schema, final List<@NonNull String> keyValues) {
307         final var keyDef = schema.getKeyDefinition();
308         final var keySize = keyDef.size();
309         final var varSize = keyValues.size();
310         if (keySize != varSize) {
311             throw new RestconfDocumentedException(
312                 "Schema for " + qname + " requires " + keySize + " key values, " + varSize + " supplied",
313                 ErrorType.PROTOCOL, keySize > varSize ? ErrorTag.MISSING_ATTRIBUTE : ErrorTag.UNKNOWN_ATTRIBUTE);
314         }
315
316         final var values = ImmutableMap.<QName, Object>builderWithExpectedSize(keySize);
317         final var tmp = stack.copy();
318         for (int i = 0; i < keySize; ++i) {
319             final QName keyName = keyDef.get(i);
320             final var child = schema.getDataChildByName(keyName);
321             tmp.enterSchemaTree(keyName);
322             values.put(keyName, prepareValueByType(tmp, child, keyValues.get(i)));
323             tmp.exit();
324         }
325
326         return NodeIdentifierWithPredicates.of(qname, values.build());
327     }
328
329     private Object prepareValueByType(final SchemaInferenceStack stack, final DataSchemaNode schemaNode,
330             final @NonNull String value) {
331         if (schemaNode instanceof TypedDataSchemaNode typedSchema) {
332             return parserJsonValue(stack, typedSchema, value);
333         }
334         throw new VerifyException("Unhandled schema " + schemaNode + " decoding '" + value + "'");
335     }
336
337     private Object parserJsonValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
338             final String value) {
339         // As per https://www.rfc-editor.org/rfc/rfc8040#page-29:
340         //            The syntax for
341         //            "api-identifier" and "key-value" MUST conform to the JSON identifier
342         //            encoding rules in Section 4 of [RFC7951]: The RESTCONF root resource
343         //            path is required.  Additional sub-resource identifiers are optional.
344         //            The characters in a key value string are constrained, and some
345         //            characters need to be percent-encoded, as described in Section 3.5.3.
346         try {
347             return databind.jsonCodecs().codecFor(schemaNode, stack).parseValue(null, value);
348         } catch (IllegalArgumentException e) {
349             throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
350                 ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
351         }
352     }
353
354     private @NonNull QNameModule resolveNamespace(final String moduleName) {
355         final var it = databind.modelContext().findModuleStatements(moduleName).iterator();
356         if (it.hasNext()) {
357             return it.next().localQNameModule();
358         }
359         throw new RestconfDocumentedException("Failed to lookup for module with name '" + moduleName + "'.",
360             ErrorType.PROTOCOL, ErrorTag.UNKNOWN_ELEMENT);
361     }
362 }