Eliminate NormalizedNodePayload
[netconf.git] / restconf / restconf-nb / src / main / java / org / opendaylight / restconf / server / spi / ApiPathCanonizer.java
1 /*
2  * Copyright (c) 2024 PANTHEON.tech, s.r.o. 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 java.util.Objects.requireNonNull;
11
12 import com.google.common.base.VerifyException;
13 import com.google.common.collect.ImmutableList;
14 import java.io.IOException;
15 import org.eclipse.jdt.annotation.NonNull;
16 import org.opendaylight.restconf.api.ApiPath;
17 import org.opendaylight.restconf.api.ApiPath.ApiIdentifier;
18 import org.opendaylight.restconf.api.ApiPath.ListInstance;
19 import org.opendaylight.restconf.api.ApiPath.Step;
20 import org.opendaylight.restconf.common.errors.RestconfDocumentedException;
21 import org.opendaylight.restconf.server.api.DatabindContext;
22 import org.opendaylight.yangtools.yang.common.ErrorTag;
23 import org.opendaylight.yangtools.yang.common.ErrorType;
24 import org.opendaylight.yangtools.yang.common.QName;
25 import org.opendaylight.yangtools.yang.common.QNameModule;
26 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier;
27 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifier;
28 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeIdentifierWithPredicates;
29 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.NodeWithValue;
30 import org.opendaylight.yangtools.yang.data.api.YangInstanceIdentifier.PathArgument;
31 import org.opendaylight.yangtools.yang.data.codec.gson.JSONCodec;
32 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext;
33 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.Composite;
34 import org.opendaylight.yangtools.yang.data.util.DataSchemaContext.PathMixin;
35 import org.opendaylight.yangtools.yang.model.api.LeafListSchemaNode;
36 import org.opendaylight.yangtools.yang.model.api.LeafSchemaNode;
37 import org.opendaylight.yangtools.yang.model.api.ListSchemaNode;
38 import org.opendaylight.yangtools.yang.model.api.TypedDataSchemaNode;
39 import org.opendaylight.yangtools.yang.model.util.SchemaInferenceStack;
40
41 /**
42  * Utility class for turning {@link YangInstanceIdentifier}s into {@link ApiPath}s via
43  * {@link #dataToApiPath(YangInstanceIdentifier)}.
44  */
45 public final class ApiPathCanonizer {
46     private final @NonNull DatabindContext databind;
47
48     public ApiPathCanonizer(final DatabindContext databind) {
49         this.databind = requireNonNull(databind);
50     }
51
52     /**
53      * Return the canonical {@link ApiPath} for specified {@link YangInstanceIdentifier}.
54      *
55      * @param path {@link YangInstanceIdentifier} to canonicalize
56      * @return An {@link ApiPath}
57      */
58     public @NonNull ApiPath dataToApiPath(final YangInstanceIdentifier path) {
59         final var it = path.getPathArguments().iterator();
60         if (!it.hasNext()) {
61             return ApiPath.empty();
62         }
63
64         final var stack = SchemaInferenceStack.of(databind.modelContext());
65         final var builder = ImmutableList.<Step>builder();
66         DataSchemaContext context = databind.schemaTree().getRoot();
67         QNameModule parentModule = null;
68         do {
69             final var arg = it.next();
70
71             // get module of the parent
72             if (!(context instanceof PathMixin)) {
73                 parentModule = context.dataSchemaNode().getQName().getModule();
74             }
75
76             final var childContext = context instanceof Composite composite ? composite.enterChild(stack, arg) : null;
77             if (childContext == null) {
78                 throw new RestconfDocumentedException(
79                     "Invalid input '%s': schema for argument '%s' (after '%s') not found".formatted(path, arg,
80                         ApiPath.of(builder.build())), ErrorType.APPLICATION, ErrorTag.UNKNOWN_ELEMENT);
81             }
82
83             context = childContext;
84
85             // only output PathArguments which are not inherent to YangInstanceIdentifier structure
86             if (!(childContext instanceof PathMixin)) {
87                 builder.add(argToStep(arg, parentModule, stack, context));
88             }
89         } while (it.hasNext());
90
91         return new ApiPath(builder.build());
92     }
93
94     private @NonNull Step argToStep(final PathArgument arg, final QNameModule prevNamespace,
95             final SchemaInferenceStack stack, final DataSchemaContext context) {
96         // append namespace before every node which is defined in other module than its parent
97         // condition is satisfied also for the first path argument
98         final var nodeType = arg.getNodeType();
99         final var module = nodeType.getModule().equals(prevNamespace) ? null : resolvePrefix(nodeType);
100         final var identifier = nodeType.unbind();
101
102         // NodeIdentifier maps to an ApiIdentifier
103         if (arg instanceof NodeIdentifier) {
104             return new ApiIdentifier(module, identifier);
105         }
106
107         // NodeWithValue addresses a LeafSetEntryNode and maps to a ListInstance with a single value
108         final var schema = context.dataSchemaNode();
109         if (arg instanceof NodeWithValue<?> withValue) {
110             if (!(schema instanceof LeafListSchemaNode leafList)) {
111                 throw new RestconfDocumentedException(
112                     "Argument '%s' does not map to a leaf-list, but %s".formatted(arg, schema),
113                     ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
114             }
115             return ListInstance.of(module, identifier, valueToString(stack, leafList, withValue.getValue()));
116         }
117
118         // The only remaining case is NodeIdentifierWrithPredicates, verify that instead of an explicit cast
119         if (!(arg instanceof NodeIdentifierWithPredicates withPredicates)) {
120             throw new VerifyException("Unhandled " + arg);
121         }
122         // A NodeIdentifierWithPredicates adresses a MapEntryNode and maps to a ListInstance with one or more values:
123         // 1) schema has to be a ListSchemaNode
124         if (!(schema instanceof ListSchemaNode list)) {
125             throw new RestconfDocumentedException(
126                 "Argument '%s' does not map to a list, but %s".formatted(arg, schema),
127                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
128         }
129         // 2) the key definition must be non-empty
130         final var keyDef = list.getKeyDefinition();
131         final var size = keyDef.size();
132         if (size == 0) {
133             throw new RestconfDocumentedException(
134                 "Argument '%s' maps a list without any keys %s".formatted(arg, schema),
135                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
136         }
137         // 3) the number of predicates has to match the number of keys
138         if (size != withPredicates.size()) {
139             throw new RestconfDocumentedException(
140                 "Argument '%s' does not match required keys %s".formatted(arg, keyDef),
141                 ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
142         }
143
144         // ListSchemaNode implies the context is a composite, verify that instead of an unexplained cast when we look
145         // up the schema for individual keys
146         if (!(context instanceof Composite composite)) {
147             throw new VerifyException("Unexpected non-composite " + context + " with " + list);
148         }
149
150         final var builder = ImmutableList.<String>builderWithExpectedSize(size);
151         for (var key : keyDef) {
152             final var value = withPredicates.getValue(key);
153             if (value == null) {
154                 throw new RestconfDocumentedException("Argument '%s' is missing predicate for %s".formatted(arg, key),
155                     ErrorType.APPLICATION, ErrorTag.INVALID_VALUE);
156             }
157
158             final var tmpStack = stack.copy();
159             final var keyContext = composite.enterChild(tmpStack, key);
160             if (keyContext == null) {
161                 throw new VerifyException("Failed to find key " + key + " in " + composite);
162             }
163             if (!(keyContext.dataSchemaNode() instanceof LeafSchemaNode leaf)) {
164                 throw new VerifyException("Key " + key + " maps to non-leaf context " + keyContext);
165             }
166             builder.add(valueToString(tmpStack, leaf, value));
167         }
168         return ListInstance.of(module, identifier, builder.build());
169     }
170
171     private <T> @NonNull String valueToString(final SchemaInferenceStack stack, final TypedDataSchemaNode schema,
172             final T value) {
173         @SuppressWarnings("unchecked")
174         final var codec = (JSONCodec<T>) databind.jsonCodecs().codecFor(schema, stack);
175         try (var jsonWriter = new HackJsonWriter()) {
176             codec.writeValue(jsonWriter, value);
177             return jsonWriter.acquireCaptured().rawString();
178         } catch (IOException e) {
179             throw new IllegalStateException("Failed to serialize '" + value + "'", e);
180         }
181     }
182
183     /**
184      * Create prefix of namespace from {@link QName}.
185      *
186      * @param qname {@link QName}
187      * @return {@link String}
188      */
189     private @NonNull String resolvePrefix(final QName qname) {
190         return databind.modelContext().findModuleStatement(qname.getModule()).orElseThrow().argument().getLocalName();
191     }
192 }