2 * Copyright (c) 2016 Cisco Systems, Inc. and others. All rights reserved.
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
8 package org.opendaylight.restconf.server.spi;
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;
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.text.ParseException;
19 import java.util.ArrayList;
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;
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.
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.
66 public final class ApiPathNormalizer implements PointNormalizer {
67 private final @NonNull DatabindContext databind;
69 public ApiPathNormalizer(final DatabindContext databind) {
70 this.databind = requireNonNull(databind);
73 public @NonNull DatabindPath normalizePath(final ApiPath apiPath) {
74 final var it = apiPath.steps().iterator();
76 return new Data(databind);
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
83 // We therefore peel that first iteration here and not worry about those details in further iterations
85 final var firstModule = step.module();
86 if (firstModule == null) {
87 throw new RestconfDocumentedException(
88 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
89 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
92 var namespace = resolveNamespace(firstModule);
93 var qname = step.identifier().bindTo(namespace);
95 // We go through more modern APIs here to get this special out of the way quickly
96 final var modelContext = databind.modelContext();
97 final var optRpc = modelContext.findModuleStatement(namespace).orElseThrow()
98 .findSchemaTreeNode(RpcEffectiveStatement.class, qname);
99 if (optRpc.isPresent()) {
100 final var rpc = optRpc.orElseThrow();
102 // We have found an RPC match,
104 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
105 + "therefore it must be the only step present", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
107 if (step instanceof ListInstance) {
108 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
109 + "therefore it must not contain key values", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
112 final var stack = SchemaInferenceStack.of(modelContext);
113 final var stmt = stack.enterSchemaTree(rpc.argument());
114 verify(rpc.equals(stmt), "Expecting %s, inferred %s", rpc, stmt);
115 return new Rpc(databind, stack.toInference(), rpc);
118 final var stack = SchemaInferenceStack.of(modelContext);
119 final var path = new ArrayList<PathArgument>();
120 DataSchemaContext parentNode = databind.schemaTree().getRoot();
122 final var parentSchema = parentNode.dataSchemaNode();
123 if (parentSchema instanceof ActionNodeContainer actionParent) {
124 final var optAction = actionParent.findAction(qname);
125 if (optAction.isPresent()) {
126 final var action = optAction.orElseThrow();
129 throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
130 + "therefore it must not continue past it", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
132 if (step instanceof ListInstance) {
133 throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
134 + "therefore it must not contain key values",
135 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
138 final var stmt = stack.enterSchemaTree(qname);
139 final var actionStmt = action.asEffectiveStatement();
140 verify(actionStmt.equals(stmt), "Expecting %s, inferred %s", actionStmt, stmt);
142 return new Action(databind, stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
146 // Resolve the child step with respect to data schema tree
147 final var found = parentNode instanceof DataSchemaContext.Composite composite
148 ? composite.enterChild(stack, qname) : null;
150 throw new RestconfDocumentedException("Schema for '" + qname + "' not found",
151 ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
154 // Now add all mixins encountered to the path
155 var childNode = found;
156 while (childNode instanceof PathMixin currentMixin) {
157 path.add(currentMixin.mixinPathStep());
158 childNode = verifyNotNull(currentMixin.enterChild(stack, qname),
159 "Mixin %s is missing child for %s while resolving %s", childNode, qname, found);
162 final PathArgument pathArg;
163 if (step instanceof ListInstance listStep) {
164 final var values = listStep.keyValues();
165 final var schema = childNode.dataSchemaNode();
166 if (schema instanceof ListSchemaNode listSchema) {
167 pathArg = prepareNodeWithPredicates(stack, qname, listSchema, values);
168 } else if (schema instanceof LeafListSchemaNode leafListSchema) {
169 if (values.size() != 1) {
170 throw new RestconfDocumentedException("Entry '" + qname + "' requires one value predicate.",
171 ErrorType.PROTOCOL, ErrorTag.BAD_ATTRIBUTE);
173 pathArg = new NodeWithValue<>(qname, parserJsonValue(stack, leafListSchema, values.get(0)));
175 throw new RestconfDocumentedException(
176 "Entry '" + qname + "' does not take a key or value predicate.",
177 ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
180 if (childNode.dataSchemaNode() instanceof ListSchemaNode list && !list.getKeyDefinition().isEmpty()) {
181 throw new RestconfDocumentedException(
182 "Entry '" + qname + "' requires key or value predicate to be present.",
183 ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
185 pathArg = childNode.getPathStep();
191 return new Data(databind, stack.toInference(), YangInstanceIdentifier.of(path), childNode);
194 parentNode = childNode;
196 final var module = step.module();
197 if (module != null) {
198 namespace = resolveNamespace(module);
201 qname = step.identifier().bindTo(namespace);
205 public @NonNull Data normalizeDataPath(final ApiPath apiPath) {
206 final var path = normalizePath(apiPath);
207 if (path instanceof Data dataPath) {
210 throw new RestconfDocumentedException("Point '" + apiPath + "' resolves to non-data " + path,
211 ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
214 public static @NonNull Data normalizeSubResource(final Data resource, final ApiPath subResource) {
215 // If subResource is empty just return the resource
216 final var urlPath = resource.instance();
217 if (subResource.steps().isEmpty()) {
220 final var normalizer = new ApiPathNormalizer(resource.databind());
221 if (urlPath.isEmpty()) {
222 // URL indicates the datastore resource, let's just normalize targetPath
223 return normalizer.normalizeDataPath(subResource);
226 // FIXME: We are re-parsing the concatenation. We should provide enough context for the bottom half of
227 // normalizePath() logic instead
228 final String targetUrl = normalizer.canonicalize(urlPath).toString() + "/" + subResource.toString();
230 return normalizer.normalizeDataPath(ApiPath.parse(targetUrl));
231 } catch (ParseException e) {
232 throw new RestconfDocumentedException("Failed to parse target " + targetUrl,
233 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE, e);
238 public PathArgument normalizePoint(final ApiPath value) {
239 final var path = normalizePath(value);
240 if (path instanceof Data dataPath) {
241 final var lastArg = dataPath.instance().getLastPathArgument();
242 if (lastArg != null) {
245 throw new IllegalArgumentException("Point '" + value + "' resolves to an empty path");
247 throw new IllegalArgumentException("Point '" + value + "' resolves to non-data " + path);
250 public @NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
251 final var steps = apiPath.steps();
252 return switch (steps.size()) {
253 case 0 -> throw new RestconfDocumentedException("RPC name must be present", ErrorType.PROTOCOL,
254 ErrorTag.DATA_MISSING);
255 case 1 -> normalizeRpcPath(steps.get(0));
256 default -> throw new RestconfDocumentedException(apiPath + " does not refer to an RPC", ErrorType.PROTOCOL,
257 ErrorTag.DATA_MISSING);
261 public @NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
262 final var firstModule = step.module();
263 if (firstModule == null) {
264 throw new RestconfDocumentedException(
265 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
266 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
269 final var namespace = resolveNamespace(firstModule);
270 final var qname = step.identifier().bindTo(namespace);
271 final var stack = SchemaInferenceStack.of(databind.modelContext());
272 final SchemaTreeEffectiveStatement<?> stmt;
274 stmt = stack.enterSchemaTree(qname);
275 } catch (IllegalArgumentException e) {
276 throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
277 ErrorTag.DATA_MISSING, e);
279 if (stmt instanceof RpcEffectiveStatement rpc) {
280 return new Rpc(databind, stack.toInference(), rpc);
282 throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
283 ErrorTag.DATA_MISSING);
286 public @NonNull InstanceReference normalizeDataOrActionPath(final ApiPath apiPath) {
287 // FIXME: optimize this
288 final var path = normalizePath(apiPath);
289 if (path instanceof Data dataPath) {
292 if (path instanceof Action actionPath) {
295 throw new RestconfDocumentedException("Unexpected path " + path, ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
299 * Return the canonical {@link ApiPath} for specified {@link YangInstanceIdentifier}.
301 * @param path {@link YangInstanceIdentifier} to canonicalize
302 * @return An {@link ApiPath}
304 public @NonNull ApiPath canonicalize(final YangInstanceIdentifier path) {
305 final var it = path.getPathArguments().iterator();
307 return ApiPath.empty();
310 final var stack = SchemaInferenceStack.of(databind.modelContext());
311 final var builder = ImmutableList.<Step>builder();
312 DataSchemaContext context = databind.schemaTree().getRoot();
313 QNameModule parentModule = null;
315 final var arg = it.next();
317 // get module of the parent
318 if (!(context instanceof PathMixin)) {
319 parentModule = context.dataSchemaNode().getQName().getModule();
322 final var childContext = context instanceof Composite composite ? composite.enterChild(stack, arg) : null;
323 if (childContext == null) {
324 throw new RestconfDocumentedException(
325 "Invalid input '%s': schema for argument '%s' (after '%s') not found".formatted(path, arg,
326 ApiPath.of(builder.build())), ErrorType.APPLICATION, ErrorTag.UNKNOWN_ELEMENT);
329 context = childContext;
330 if (childContext instanceof PathMixin) {
331 // This PathArgument is a mixed-in YangInstanceIdentifier, do not emit anything and continue
335 builder.add(canonicalize(arg, parentModule, stack, context));
336 } while (it.hasNext());
338 return new ApiPath(builder.build());
341 private @NonNull Step canonicalize(final PathArgument arg, final QNameModule prevNamespace,
342 final SchemaInferenceStack stack, final DataSchemaContext context) {
343 // append namespace before every node which is defined in other module than its parent
344 // condition is satisfied also for the first path argument
345 final var nodeType = arg.getNodeType();
346 final var module = nodeType.getModule().equals(prevNamespace) ? null : resolvePrefix(nodeType);
347 final var identifier = nodeType.unbind();
349 // NodeIdentifier maps to an ApiIdentifier
350 if (arg instanceof NodeIdentifier) {
351 return new ApiIdentifier(module, identifier);
354 // NodeWithValue addresses a LeafSetEntryNode and maps to a ListInstance with a single value
355 final var schema = context.dataSchemaNode();
356 if (arg instanceof NodeWithValue<?> withValue) {
357 if (!(schema instanceof LeafListSchemaNode leafList)) {
358 throw new RestconfDocumentedException(
359 "Argument '%s' does not map to a leaf-list, but %s".formatted(arg, schema),
360 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
362 return ListInstance.of(module, identifier, encodeValue(stack, leafList, withValue.getValue()));
365 // The only remaining case is NodeIdentifierWrithPredicates, verify that instead of an explicit cast
366 if (!(arg instanceof NodeIdentifierWithPredicates withPredicates)) {
367 throw new VerifyException("Unhandled " + arg);
369 // A NodeIdentifierWithPredicates adresses a MapEntryNode and maps to a ListInstance with one or more values:
370 // 1) schema has to be a ListSchemaNode
371 if (!(schema instanceof ListSchemaNode list)) {
372 throw new RestconfDocumentedException(
373 "Argument '%s' does not map to a list, but %s".formatted(arg, schema),
374 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
376 // 2) the key definition must be non-empty
377 final var keyDef = list.getKeyDefinition();
378 final var size = keyDef.size();
380 throw new RestconfDocumentedException(
381 "Argument '%s' maps a list without any keys %s".formatted(arg, schema),
382 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
384 // 3) the number of predicates has to match the number of keys
385 if (size != withPredicates.size()) {
386 throw new RestconfDocumentedException(
387 "Argument '%s' does not match required keys %s".formatted(arg, keyDef),
388 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
391 // ListSchemaNode implies the context is a composite, verify that instead of an unexplained cast when we look
392 // up the schema for individual keys
393 if (!(context instanceof Composite composite)) {
394 throw new VerifyException("Unexpected non-composite " + context + " with " + list);
397 final var builder = ImmutableList.<String>builderWithExpectedSize(size);
398 for (var key : keyDef) {
399 final var value = withPredicates.getValue(key);
401 throw new RestconfDocumentedException("Argument '%s' is missing predicate for %s".formatted(arg, key),
402 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
405 final var tmpStack = stack.copy();
406 final var keyContext = composite.enterChild(tmpStack, key);
407 if (keyContext == null) {
408 throw new VerifyException("Failed to find key " + key + " in " + composite);
410 if (!(keyContext.dataSchemaNode() instanceof LeafSchemaNode leaf)) {
411 throw new VerifyException("Key " + key + " maps to non-leaf context " + keyContext);
413 builder.add(encodeValue(tmpStack, leaf, value));
415 return ListInstance.of(module, identifier, builder.build());
418 private String encodeValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schema, final Object value) {
419 @SuppressWarnings("unchecked")
420 final var codec = (JSONCodec<Object>) databind.jsonCodecs().codecFor(schema, stack);
421 try (var jsonWriter = new HackJsonWriter()) {
422 codec.writeValue(jsonWriter, value);
423 return jsonWriter.acquireCaptured().rawString();
424 } catch (IOException e) {
425 throw new IllegalStateException("Failed to serialize '" + value + "'", e);
429 private NodeIdentifierWithPredicates prepareNodeWithPredicates(final SchemaInferenceStack stack, final QName qname,
430 final @NonNull ListSchemaNode schema, final List<@NonNull String> keyValues) {
431 final var keyDef = schema.getKeyDefinition();
432 final var keySize = keyDef.size();
433 final var varSize = keyValues.size();
434 if (keySize != varSize) {
435 throw new RestconfDocumentedException(
436 "Schema for " + qname + " requires " + keySize + " key values, " + varSize + " supplied",
437 ErrorType.PROTOCOL, keySize > varSize ? ErrorTag.MISSING_ATTRIBUTE : ErrorTag.UNKNOWN_ATTRIBUTE);
440 final var values = ImmutableMap.<QName, Object>builderWithExpectedSize(keySize);
441 final var tmp = stack.copy();
442 for (int i = 0; i < keySize; ++i) {
443 final QName keyName = keyDef.get(i);
444 final var child = schema.getDataChildByName(keyName);
445 tmp.enterSchemaTree(keyName);
446 values.put(keyName, prepareValueByType(tmp, child, keyValues.get(i)));
450 return NodeIdentifierWithPredicates.of(qname, values.build());
453 private Object prepareValueByType(final SchemaInferenceStack stack, final DataSchemaNode schemaNode,
454 final @NonNull String value) {
455 if (schemaNode instanceof TypedDataSchemaNode typedSchema) {
456 return parserJsonValue(stack, typedSchema, value);
458 throw new VerifyException("Unhandled schema " + schemaNode + " decoding '" + value + "'");
461 private Object parserJsonValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
462 final String value) {
463 // As per https://www.rfc-editor.org/rfc/rfc8040#page-29:
465 // "api-identifier" and "key-value" MUST conform to the JSON identifier
466 // encoding rules in Section 4 of [RFC7951]: The RESTCONF root resource
467 // path is required. Additional sub-resource identifiers are optional.
468 // The characters in a key value string are constrained, and some
469 // characters need to be percent-encoded, as described in Section 3.5.3.
471 return databind.jsonCodecs().codecFor(schemaNode, stack).parseValue(null, value);
472 } catch (IllegalArgumentException e) {
473 throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
474 ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
478 private @NonNull QNameModule resolveNamespace(final String moduleName) {
479 final var it = databind.modelContext().findModuleStatements(moduleName).iterator();
481 return it.next().localQNameModule();
483 throw new RestconfDocumentedException("Failed to lookup for module with name '" + moduleName + "'.",
484 ErrorType.PROTOCOL, ErrorTag.UNKNOWN_ELEMENT);
488 * Create prefix of namespace from {@link QName}.
490 * @param qname {@link QName}
491 * @return {@link String}
493 private @NonNull String resolvePrefix(final QName qname) {
494 return databind.modelContext().findModuleStatement(qname.getModule()).orElseThrow().argument().getLocalName();