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.util.ArrayList;
19 import java.util.List;
20 import org.eclipse.jdt.annotation.NonNull;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
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.spi.ApiPathNormalizer.Path.Action;
30 import org.opendaylight.restconf.server.spi.ApiPathNormalizer.Path.Data;
31 import org.opendaylight.restconf.server.spi.ApiPathNormalizer.Path.Rpc;
32 import org.opendaylight.yangtools.yang.common.ErrorTag;
33 import org.opendaylight.yangtools.yang.common.ErrorType;
34 import org.opendaylight.yangtools.yang.common.QName;
35 import org.opendaylight.yangtools.yang.common.QNameModule;
36 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
37 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
38 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
39 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
40 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
41 import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodec;
42 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext;
43 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.Composite;
44 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.PathMixin;
45 import org.opendaylight.yangtools.yang.model.api.ActionNodeContainer;
46 import org.opendaylight.yangtools.yang.model.api.DataSchemaNode;
47 import org.opendaylight.yangtools.yang.model.api.EffectiveStatementInference;
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.ActionEffectiveStatement;
53 import org.opendaylight.yangtools.yang.model.api.stmt.InputEffectiveStatement;
54 import org.opendaylight.yangtools.yang.model.api.stmt.OutputEffectiveStatement;
55 import org.opendaylight.yangtools.yang.model.api.stmt.RpcEffectiveStatement;
56 import org.opendaylight.yangtools.yang.model.api.stmt.SchemaTreeEffectiveStatement;
57 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
58 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack.Inference;
61 * Utility for normalizing {@link ApiPath}s. An {@link ApiPath} can represent a number of different constructs, as
62 * denoted to in the {@link Path} interface hierarchy.
65 * This process is governed by
66 * <a href="https://www.rfc-editor.org/rfc/rfc8040#section-3.5.3">RFC8040, section 3.5.3</a>. The URI provides the
67 * equivalent of NETCONF XML filter encoding, with data values being escaped RFC7891 strings.
69 public final class ApiPathNormalizer implements PointNormalizer {
71 * A normalized {@link ApiPath}. This can be either
73 * <li>a {@link Data} pointing to a datastore resource, or</li>
74 * <li>an {@link Rpc} pointing to a YANG {@code rpc} statement, or</li>
75 * <li>an {@link Action} pointing to an instantiation of a YANG {@code action} statement</li>
79 public sealed interface Path {
81 * Returns the {@link EffectiveStatementInference} made by this path.
83 * @return the {@link EffectiveStatementInference} made by this path
85 Inference inference();
88 * A {@link Path} denoting an invocation of a YANG {@code action}.
90 * @param inference the {@link EffectiveStatementInference} made by this path
91 * @param instance the {@link YangInstanceIdentifier} of the instance being referenced, guaranteed to be
93 * @param action the {@code action}
95 record Action(Inference inference, YangInstanceIdentifier instance, ActionEffectiveStatement action)
96 implements OperationPath, InstanceReference {
98 requireNonNull(inference);
99 requireNonNull(action);
100 if (instance.isEmpty()) {
101 throw new IllegalArgumentException("action must be instantiated on a data resource");
106 public InputEffectiveStatement inputStatement() {
107 return action.input();
111 public OutputEffectiveStatement outputStatement() {
112 return action.output();
117 * A {@link Path} denoting a datastore instance.
119 * @param inference the {@link EffectiveStatementInference} made by this path
120 * @param instance the {@link YangInstanceIdentifier} of the instance being referenced,
121 * {@link YangInstanceIdentifier#empty()} denotes the datastore
122 * @param schema the {@link DataSchemaContext} of the datastore instance
124 // FIXME: split into 'Datastore' and 'Data' with non-empty instance, so we can bind to correct
125 // instance-identifier semantics, which does not allow YangInstanceIdentifier.empty()
126 record Data(Inference inference, YangInstanceIdentifier instance, DataSchemaContext schema)
127 implements InstanceReference {
129 requireNonNull(inference);
130 requireNonNull(instance);
131 requireNonNull(schema);
136 * A {@link Path} denoting an invocation of a YANG {@code rpc}.
138 * @param inference the {@link EffectiveStatementInference} made by this path
139 * @param rpc the {@code rpc}
141 record Rpc(Inference inference, RpcEffectiveStatement rpc) implements OperationPath {
143 requireNonNull(inference);
148 public InputEffectiveStatement inputStatement() {
153 public OutputEffectiveStatement outputStatement() {
160 * An intermediate trait of {@link Path}s which are referencing a YANG data resource. This can be either
161 * a {@link Data}, or an {@link Action}}.
164 public sealed interface InstanceReference extends Path {
166 * Returns the {@link YangInstanceIdentifier} of the instance being referenced.
168 * @return the {@link YangInstanceIdentifier} of the instance being referenced,
169 * {@link YangInstanceIdentifier#empty()} denotes the datastora
171 YangInstanceIdentifier instance();
175 * An intermediate trait of {@link Path}s which are referencing a YANG operation. This can be either
176 * an {@link Action} on an {@link Rpc}.
179 public sealed interface OperationPath extends Path {
181 * Returns the {@code input} statement of this operation.
183 * @return the {@code input} statement of this operation
185 InputEffectiveStatement inputStatement();
188 * Returns the {@code output} statement of this operation.
190 * @return the {@code output} statement of this operation
192 OutputEffectiveStatement outputStatement();
195 private final @NonNull DatabindContext databind;
197 public ApiPathNormalizer(final DatabindContext databind) {
198 this.databind = requireNonNull(databind);
201 public @NonNull Path normalizePath(final ApiPath apiPath) {
202 final var it = apiPath.steps().iterator();
204 return new Data(Inference.ofDataTreePath(databind.modelContext()), YangInstanceIdentifier.of(),
205 databind.schemaTree().getRoot());
208 // First step is somewhat special:
209 // - it has to contain a module qualifier
210 // - it has to consider RPCs, for which we need SchemaContext
212 // We therefore peel that first iteration here and not worry about those details in further iterations
213 var step = it.next();
214 final var firstModule = step.module();
215 if (firstModule == null) {
216 throw new RestconfDocumentedException(
217 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
218 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
221 var namespace = resolveNamespace(firstModule);
222 var qname = step.identifier().bindTo(namespace);
224 // We go through more modern APIs here to get this special out of the way quickly
225 final var modelContext = databind.modelContext();
226 final var optRpc = modelContext.findModuleStatement(namespace).orElseThrow()
227 .findSchemaTreeNode(RpcEffectiveStatement.class, qname);
228 if (optRpc.isPresent()) {
229 final var rpc = optRpc.orElseThrow();
231 // We have found an RPC match,
233 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
234 + "therefore it must be the only step present", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
236 if (step instanceof ListInstance) {
237 throw new RestconfDocumentedException("First step in the path resolves to RPC '" + qname + "' and "
238 + "therefore it must not contain key values", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
241 final var stack = SchemaInferenceStack.of(modelContext);
242 final var stmt = stack.enterSchemaTree(rpc.argument());
243 verify(rpc.equals(stmt), "Expecting %s, inferred %s", rpc, stmt);
244 return new Rpc(stack.toInference(), rpc);
247 final var stack = SchemaInferenceStack.of(modelContext);
248 final var path = new ArrayList<PathArgument>();
249 DataSchemaContext parentNode = databind.schemaTree().getRoot();
251 final var parentSchema = parentNode.dataSchemaNode();
252 if (parentSchema instanceof ActionNodeContainer actionParent) {
253 final var optAction = actionParent.findAction(qname);
254 if (optAction.isPresent()) {
255 final var action = optAction.orElseThrow();
258 throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
259 + "therefore it must not continue past it", ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
261 if (step instanceof ListInstance) {
262 throw new RestconfDocumentedException("Request path resolves to action '" + qname + "' and "
263 + "therefore it must not contain key values",
264 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
267 final var stmt = stack.enterSchemaTree(qname);
268 final var actionStmt = action.asEffectiveStatement();
269 verify(actionStmt.equals(stmt), "Expecting %s, inferred %s", actionStmt, stmt);
271 return new Action(stack.toInference(), YangInstanceIdentifier.of(path), actionStmt);
275 // Resolve the child step with respect to data schema tree
276 final var found = parentNode instanceof DataSchemaContext.Composite composite
277 ? composite.enterChild(stack, qname) : null;
279 throw new RestconfDocumentedException("Schema for '" + qname + "' not found",
280 ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
283 // Now add all mixins encountered to the path
284 var childNode = found;
285 while (childNode instanceof PathMixin currentMixin) {
286 path.add(currentMixin.mixinPathStep());
287 childNode = verifyNotNull(currentMixin.enterChild(stack, qname),
288 "Mixin %s is missing child for %s while resolving %s", childNode, qname, found);
291 final PathArgument pathArg;
292 if (step instanceof ListInstance listStep) {
293 final var values = listStep.keyValues();
294 final var schema = childNode.dataSchemaNode();
295 if (schema instanceof ListSchemaNode listSchema) {
296 pathArg = prepareNodeWithPredicates(stack, qname, listSchema, values);
297 } else if (schema instanceof LeafListSchemaNode leafListSchema) {
298 if (values.size() != 1) {
299 throw new RestconfDocumentedException("Entry '" + qname + "' requires one value predicate.",
300 ErrorType.PROTOCOL, ErrorTag.BAD_ATTRIBUTE);
302 pathArg = new NodeWithValue<>(qname, parserJsonValue(stack, leafListSchema, values.get(0)));
304 throw new RestconfDocumentedException(
305 "Entry '" + qname + "' does not take a key or value predicate.",
306 ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
309 if (childNode.dataSchemaNode() instanceof ListSchemaNode list && !list.getKeyDefinition().isEmpty()) {
310 throw new RestconfDocumentedException(
311 "Entry '" + qname + "' requires key or value predicate to be present.",
312 ErrorType.PROTOCOL, ErrorTag.MISSING_ATTRIBUTE);
314 pathArg = childNode.getPathStep();
320 return new Data(stack.toInference(), YangInstanceIdentifier.of(path), childNode);
323 parentNode = childNode;
325 final var module = step.module();
326 if (module != null) {
327 namespace = resolveNamespace(module);
330 qname = step.identifier().bindTo(namespace);
334 public @NonNull Data normalizeDataPath(final ApiPath apiPath) {
335 final var path = normalizePath(apiPath);
336 if (path instanceof Data dataPath) {
339 throw new RestconfDocumentedException("Point '" + apiPath + "' resolves to non-data " + path,
340 ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
344 public PathArgument normalizePoint(final ApiPath value) {
345 final var path = normalizePath(value);
346 if (path instanceof Data dataPath) {
347 final var lastArg = dataPath.instance().getLastPathArgument();
348 if (lastArg != null) {
351 throw new IllegalArgumentException("Point '" + value + "' resolves to an empty path");
353 throw new IllegalArgumentException("Point '" + value + "' resolves to non-data " + path);
356 public Path.@NonNull Rpc normalizeRpcPath(final ApiPath apiPath) {
357 final var steps = apiPath.steps();
358 return switch (steps.size()) {
359 case 0 -> throw new RestconfDocumentedException("RPC name must be present", ErrorType.PROTOCOL,
360 ErrorTag.DATA_MISSING);
361 case 1 -> normalizeRpcPath(steps.get(0));
362 default -> throw new RestconfDocumentedException(apiPath + " does not refer to an RPC", ErrorType.PROTOCOL,
363 ErrorTag.DATA_MISSING);
367 public Path.@NonNull Rpc normalizeRpcPath(final ApiPath.Step step) {
368 final var firstModule = step.module();
369 if (firstModule == null) {
370 throw new RestconfDocumentedException(
371 "First member must use namespace-qualified form, '" + step.identifier() + "' does not",
372 ErrorType.PROTOCOL, ErrorTag.MALFORMED_MESSAGE);
375 final var namespace = resolveNamespace(firstModule);
376 final var qname = step.identifier().bindTo(namespace);
377 final var stack = SchemaInferenceStack.of(databind.modelContext());
378 final SchemaTreeEffectiveStatement<?> stmt;
380 stmt = stack.enterSchemaTree(qname);
381 } catch (IllegalArgumentException e) {
382 throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
383 ErrorTag.DATA_MISSING, e);
385 if (stmt instanceof RpcEffectiveStatement rpc) {
386 return new Rpc(stack.toInference(), rpc);
388 throw new RestconfDocumentedException(qname + " does not refer to an RPC", ErrorType.PROTOCOL,
389 ErrorTag.DATA_MISSING);
392 public @NonNull InstanceReference normalizeDataOrActionPath(final ApiPath apiPath) {
393 // FIXME: optimize this
394 final var path = normalizePath(apiPath);
395 if (path instanceof Data dataPath) {
398 if (path instanceof Action actionPath) {
401 throw new RestconfDocumentedException("Unexpected path " + path, ErrorType.PROTOCOL, ErrorTag.DATA_MISSING);
405 * Return the canonical {@link ApiPath} for specified {@link YangInstanceIdentifier}.
407 * @param path {@link YangInstanceIdentifier} to canonicalize
408 * @return An {@link ApiPath}
410 public @NonNull ApiPath canonicalize(final YangInstanceIdentifier path) {
411 final var it = path.getPathArguments().iterator();
413 return ApiPath.empty();
416 final var stack = SchemaInferenceStack.of(databind.modelContext());
417 final var builder = ImmutableList.<Step>builder();
418 DataSchemaContext context = databind.schemaTree().getRoot();
419 QNameModule parentModule = null;
421 final var arg = it.next();
423 // get module of the parent
424 if (!(context instanceof PathMixin)) {
425 parentModule = context.dataSchemaNode().getQName().getModule();
428 final var childContext = context instanceof Composite composite ? composite.enterChild(stack, arg) : null;
429 if (childContext == null) {
430 throw new RestconfDocumentedException(
431 "Invalid input '%s': schema for argument '%s' (after '%s') not found".formatted(path, arg,
432 ApiPath.of(builder.build())), ErrorType.APPLICATION, ErrorTag.UNKNOWN_ELEMENT);
435 context = childContext;
436 if (childContext instanceof PathMixin) {
437 // This PathArgument is a mixed-in YangInstanceIdentifier, do not emit anything and continue
441 builder.add(canonicalize(arg, parentModule, stack, context));
442 } while (it.hasNext());
444 return new ApiPath(builder.build());
447 private @NonNull Step canonicalize(final PathArgument arg, final QNameModule prevNamespace,
448 final SchemaInferenceStack stack, final DataSchemaContext context) {
449 // append namespace before every node which is defined in other module than its parent
450 // condition is satisfied also for the first path argument
451 final var nodeType = arg.getNodeType();
452 final var module = nodeType.getModule().equals(prevNamespace) ? null : resolvePrefix(nodeType);
453 final var identifier = nodeType.unbind();
455 // NodeIdentifier maps to an ApiIdentifier
456 if (arg instanceof NodeIdentifier) {
457 return new ApiIdentifier(module, identifier);
460 // NodeWithValue addresses a LeafSetEntryNode and maps to a ListInstance with a single value
461 final var schema = context.dataSchemaNode();
462 if (arg instanceof NodeWithValue<?> withValue) {
463 if (!(schema instanceof LeafListSchemaNode leafList)) {
464 throw new RestconfDocumentedException(
465 "Argument '%s' does not map to a leaf-list, but %s".formatted(arg, schema),
466 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
468 return ListInstance.of(module, identifier, encodeValue(stack, leafList, withValue.getValue()));
471 // The only remaining case is NodeIdentifierWrithPredicates, verify that instead of an explicit cast
472 if (!(arg instanceof NodeIdentifierWithPredicates withPredicates)) {
473 throw new VerifyException("Unhandled " + arg);
475 // A NodeIdentifierWithPredicates adresses a MapEntryNode and maps to a ListInstance with one or more values:
476 // 1) schema has to be a ListSchemaNode
477 if (!(schema instanceof ListSchemaNode list)) {
478 throw new RestconfDocumentedException(
479 "Argument '%s' does not map to a list, but %s".formatted(arg, schema),
480 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
482 // 2) the key definition must be non-empty
483 final var keyDef = list.getKeyDefinition();
484 final var size = keyDef.size();
486 throw new RestconfDocumentedException(
487 "Argument '%s' maps a list without any keys %s".formatted(arg, schema),
488 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
490 // 3) the number of predicates has to match the number of keys
491 if (size != withPredicates.size()) {
492 throw new RestconfDocumentedException(
493 "Argument '%s' does not match required keys %s".formatted(arg, keyDef),
494 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
497 // ListSchemaNode implies the context is a composite, verify that instead of an unexplained cast when we look
498 // up the schema for individual keys
499 if (!(context instanceof Composite composite)) {
500 throw new VerifyException("Unexpected non-composite " + context + " with " + list);
503 final var builder = ImmutableList.<String>builderWithExpectedSize(size);
504 for (var key : keyDef) {
505 final var value = withPredicates.getValue(key);
507 throw new RestconfDocumentedException("Argument '%s' is missing predicate for %s".formatted(arg, key),
508 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
511 final var tmpStack = stack.copy();
512 final var keyContext = composite.enterChild(tmpStack, key);
513 if (keyContext == null) {
514 throw new VerifyException("Failed to find key " + key + " in " + composite);
516 if (!(keyContext.dataSchemaNode() instanceof LeafSchemaNode leaf)) {
517 throw new VerifyException("Key " + key + " maps to non-leaf context " + keyContext);
519 builder.add(encodeValue(tmpStack, leaf, value));
521 return ListInstance.of(module, identifier, builder.build());
524 private String encodeValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schema, final Object value) {
525 @SuppressWarnings("unchecked")
526 final var codec = (JSONCodec<Object>) databind.jsonCodecs().codecFor(schema, stack);
527 try (var jsonWriter = new HackJsonWriter()) {
528 codec.writeValue(jsonWriter, value);
529 return jsonWriter.acquireCaptured().rawString();
530 } catch (IOException e) {
531 throw new IllegalStateException("Failed to serialize '" + value + "'", e);
535 private NodeIdentifierWithPredicates prepareNodeWithPredicates(final SchemaInferenceStack stack, final QName qname,
536 final @NonNull ListSchemaNode schema, final List<@NonNull String> keyValues) {
537 final var keyDef = schema.getKeyDefinition();
538 final var keySize = keyDef.size();
539 final var varSize = keyValues.size();
540 if (keySize != varSize) {
541 throw new RestconfDocumentedException(
542 "Schema for " + qname + " requires " + keySize + " key values, " + varSize + " supplied",
543 ErrorType.PROTOCOL, keySize > varSize ? ErrorTag.MISSING_ATTRIBUTE : ErrorTag.UNKNOWN_ATTRIBUTE);
546 final var values = ImmutableMap.<QName, Object>builderWithExpectedSize(keySize);
547 final var tmp = stack.copy();
548 for (int i = 0; i < keySize; ++i) {
549 final QName keyName = keyDef.get(i);
550 final var child = schema.getDataChildByName(keyName);
551 tmp.enterSchemaTree(keyName);
552 values.put(keyName, prepareValueByType(tmp, child, keyValues.get(i)));
556 return NodeIdentifierWithPredicates.of(qname, values.build());
559 private Object prepareValueByType(final SchemaInferenceStack stack, final DataSchemaNode schemaNode,
560 final @NonNull String value) {
561 if (schemaNode instanceof TypedDataSchemaNode typedSchema) {
562 return parserJsonValue(stack, typedSchema, value);
564 throw new VerifyException("Unhandled schema " + schemaNode + " decoding '" + value + "'");
567 private Object parserJsonValue(final SchemaInferenceStack stack, final TypedDataSchemaNode schemaNode,
568 final String value) {
569 // As per https://www.rfc-editor.org/rfc/rfc8040#page-29:
571 // "api-identifier" and "key-value" MUST conform to the JSON identifier
572 // encoding rules in Section 4 of [RFC7951]: The RESTCONF root resource
573 // path is required. Additional sub-resource identifiers are optional.
574 // The characters in a key value string are constrained, and some
575 // characters need to be percent-encoded, as described in Section 3.5.3.
577 return databind.jsonCodecs().codecFor(schemaNode, stack).parseValue(null, value);
578 } catch (IllegalArgumentException e) {
579 throw new RestconfDocumentedException("Invalid value '" + value + "' for " + schemaNode.getQName(),
580 ErrorType.PROTOCOL, ErrorTag.INVALID_VALUE, e);
584 private @NonNull QNameModule resolveNamespace(final String moduleName) {
585 final var it = databind.modelContext().findModuleStatements(moduleName).iterator();
587 return it.next().localQNameModule();
589 throw new RestconfDocumentedException("Failed to lookup for module with name '" + moduleName + "'.",
590 ErrorType.PROTOCOL, ErrorTag.UNKNOWN_ELEMENT);
594 * Create prefix of namespace from {@link QName}.
596 * @param qname {@link QName}
597 * @return {@link String}
599 private @NonNull String resolvePrefix(final QName qname) {
600 return databind.modelContext().findModuleStatement(qname.getModule()).orElseThrow().argument().getLocalName();