Add rfc8040.ApiPath
[netconf.git] / restconf / restconf-nb-rfc8040 / src / main / java / org / opendaylight / restconf / nb / rfc8040 / ApiPathParser.java
1 /*
2  * Copyright (c) 2021 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.nb.rfc8040;
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.CharMatcher;
15 import com.google.common.collect.ImmutableList;
16 import com.google.common.collect.ImmutableList.Builder;
17 import java.text.ParseException;
18 import java.util.function.Supplier;
19 import org.eclipse.jdt.annotation.NonNull;
20 import org.eclipse.jdt.annotation.Nullable;
21 import org.opendaylight.restconf.nb.rfc8040.ApiPath.ApiIdentifier;
22 import org.opendaylight.restconf.nb.rfc8040.ApiPath.ListInstance;
23 import org.opendaylight.restconf.nb.rfc8040.ApiPath.Step;
24 import org.slf4j.Logger;
25 import org.slf4j.LoggerFactory;
26
27 /**
28  * Parser for a sequence of {@link ApiPath}'s {@link Step}s.
29  */
30 class ApiPathParser {
31     private static final Logger LOG = LoggerFactory.getLogger(ApiPathParser.class);
32
33     // FIXME: use these from YangNames
34     private static final CharMatcher IDENTIFIER_START =
35         CharMatcher.inRange('A', 'Z').or(CharMatcher.inRange('a', 'z').or(CharMatcher.is('_'))).precomputed();
36     private static final CharMatcher NOT_IDENTIFIER_PART =
37         IDENTIFIER_START.or(CharMatcher.inRange('0', '9')).or(CharMatcher.anyOf("-.")).negate().precomputed();
38
39     /**
40      * A lenient interpretation: '//' is '/', i.e. there is no segment.
41      */
42     private static final class Lenient extends ApiPathParser {
43         @Override
44         int parseStep(final String str, final int offset, final int limit) throws ParseException {
45             return offset == limit ? limit : super.parseStep(str, offset, limit);
46         }
47     }
48
49     /**
50      * A lenient interpretation: '//' is '/', i.e. there is no segment, but also log offenders
51      */
52     private static final class Logging extends ApiPathParser {
53         @FunctionalInterface
54         private interface LogMethod {
55             void logLeniency(String format, Object arg0, Object arg1);
56         }
57
58         private final LogMethod method;
59
60         Logging(final LogMethod method) {
61             this.method = requireNonNull(method);
62         }
63
64         @Override
65         int parseStep(final String str, final int offset, final int limit) throws ParseException {
66             if (offset == limit) {
67                 method.logLeniency("Ignoring duplicate slash in '{}' at offset", str, offset);
68                 return limit;
69             }
70             return super.parseStep(str, offset, limit);
71         }
72     }
73
74     private static final Supplier<@NonNull ApiPathParser> URL_FACTORY;
75
76     static {
77         // Select the correct parser implementation where consecutive slashes are concerned. We default to lenient
78         // interpretation and treat them as a single slash, but allow this to be overridden through a system property.
79         // FIXME: 3.0.0: make "reject" the default
80         final String prop = System.getProperty("org.opendaylight.restconf.url.consecutive-slashes", "allow");
81         final String treatment;
82         switch (prop) {
83             case "allow":
84                 treatment = "are treated as a single slash";
85                 URL_FACTORY = Lenient::new;
86                 break;
87             case "debug":
88                 treatment = "are treated as a single slash and will be logged";
89                 URL_FACTORY = () -> new Logging(LOG::debug);
90                 break;
91             case "warn":
92                 treatment = "are treated as a single slash and will be warned about";
93                 URL_FACTORY = () -> new Logging(LOG::warn);
94                 break;
95             case "reject":
96                 treatment = "will be rejected";
97                 URL_FACTORY = ApiPathParser::new;
98                 break;
99             default:
100                 LOG.warn("Unknown property value '{}', assuming 'reject'", prop);
101                 treatment = "will be rejected";
102                 URL_FACTORY = ApiPathParser::new;
103         }
104
105         LOG.info("Consecutive slashes in REST URLs {}", treatment);
106     }
107
108     private final Builder<Step> steps = ImmutableList.builder();
109
110     /*
111      * State tracking for creating substrings:
112      *
113      * Usually we copy spans 'src', in which case subStart captures 'start' argument to String.substring(...).
114      * If we encounter a percent escape we need to interpret as part of the string, we start building the string in
115      * subBuilder -- in which case subStart is set to -1.
116      *
117      * Note that StringBuilder is lazily-instantiated, as we have no percents at all
118      */
119     private int subStart;
120     private StringBuilder subBuilder;
121
122     // Lazily-allocated when we need to decode UTF-8. Since we touch this only when we are not expecting
123     private Utf8Buffer buf;
124
125     // the offset of the character returned from last peekBasicLatin()
126     private int nextOffset;
127
128     private ApiPathParser() {
129         // Hidden on purpose
130     }
131
132     static @NonNull ApiPathParser newStrict() {
133         return new ApiPathParser();
134     }
135
136     static @NonNull ApiPathParser newUrl() {
137         return URL_FACTORY.get();
138     }
139
140     // Grammar:
141     //   steps : step ("/" step)*
142     final @NonNull ImmutableList<@NonNull Step> parseSteps(final String str) throws ParseException {
143         int idx = 0;
144
145         // First process while we are seeing a slash
146         while (true) {
147             final int slash = str.indexOf('/', idx);
148             if (slash != -1) {
149                 final int next = parseStep(str, idx, slash);
150                 verify(next == slash, "Unconsumed bytes: %s next %s limit", next, slash);
151                 idx = next + 1;
152             } else {
153                 break;
154             }
155         }
156
157         // Now process the tail of the string
158         final int length = str.length();
159         final int next = parseStep(str, idx, length);
160         verify(next == length, "Unconsumed trailing bytes: %s next %s limit", next, length);
161
162         return steps.build();
163     }
164
165     // Grammar:
166     //   step : identifier (":" identifier)? ("=" key-value ("," key-value)*)?
167     // Note: visible for subclasses
168     int parseStep(final String str, final int offset, final int limit) throws ParseException {
169         int idx = startIdentifier(str, offset, limit);
170         while (idx < limit) {
171             final char ch = peekBasicLatin(str, idx, limit);
172             if (ch == ':') {
173                 return parseStep(endSub(str, idx), str, nextOffset, limit);
174             } else if (ch == '=') {
175                 return parseStep(null, endSub(str, idx), str, nextOffset, limit);
176             }
177             idx = continueIdentifer(idx, ch);
178         }
179
180         steps.add(new ApiIdentifier(null, endSub(str, idx)));
181         return idx;
182     }
183
184     // Starting at second identifier
185     private int parseStep(final @Nullable String module, final String str, final int offset, final int limit)
186             throws ParseException {
187         int idx = startIdentifier(str, offset, limit);
188         while (idx < limit) {
189             final char ch = peekBasicLatin(str, idx, limit);
190             if (ch == '=') {
191                 return parseStep(module, endSub(str, idx), str, nextOffset, limit);
192             }
193             idx = continueIdentifer(idx, ch);
194         }
195
196         steps.add(new ApiIdentifier(module, endSub(str, idx)));
197         return idx;
198     }
199
200     // Starting at first key-value
201     private int parseStep(final @Nullable String module, final @NonNull String identifier,
202             final String str, final int offset, final int limit) throws ParseException {
203         final var values = ImmutableList.<String>builder();
204
205         startSub(offset);
206         int idx = offset;
207         while (idx < limit) {
208             final char ch = str.charAt(idx);
209             if (ch == ',') {
210                 values.add(endSub(str, idx));
211                 startSub(++idx);
212             } else if (ch != '%') {
213                 append(ch);
214                 idx++;
215             } else {
216                 // Save current string content and capture current index for reporting
217                 final var sb = flushSub(str, idx);
218                 final int errorOffset = idx;
219
220                 var utf = buf;
221                 if (utf == null) {
222                     buf = utf = new Utf8Buffer();
223                 }
224
225                 do {
226                     utf.appendByte(parsePercent(str, idx, limit));
227                     idx += 3;
228                 } while (idx < limit && str.charAt(idx) == '%');
229
230                 utf.flushTo(sb, errorOffset);
231             }
232         }
233
234         steps.add(new ListInstance(module, identifier, values.add(endSub(str, idx)).build()));
235         return idx;
236     }
237
238     private int startIdentifier(final String str, final int offset, final int limit) throws ParseException {
239         if (offset == limit) {
240             throw new ParseException("Identifier may not be empty", offset);
241         }
242
243         startSub(offset);
244         final char ch = peekBasicLatin(str, offset, limit);
245         if (!IDENTIFIER_START.matches(ch)) {
246             throw new ParseException("Expecting [a-zA-Z_], not '" + ch + "'", offset);
247         }
248         append(ch);
249         return nextOffset;
250     }
251
252     private int continueIdentifer(final int offset, final char ch) throws ParseException {
253         if (NOT_IDENTIFIER_PART.matches(ch)) {
254             throw new ParseException("Expecting [a-zA-Z_.-], not '" + ch + "'", offset);
255         }
256         append(ch);
257         return nextOffset;
258     }
259
260     // Assert current character comes from the Basic Latin block, i.e. 00-7F.
261     // Callers are expected to pick up 'nextIdx' to resume parsing at the next character
262     private char peekBasicLatin(final String str, final int offset, final int limit) throws ParseException {
263         final char ch = str.charAt(offset);
264         if (ch == '%') {
265             final byte b = parsePercent(str, offset, limit);
266             if (b < 0) {
267                 throw new ParseException("Expecting %00-%7F, not " + str.substring(offset, limit), offset);
268             }
269
270             flushSub(str, offset);
271             nextOffset = offset + 3;
272             return (char) b;
273         }
274
275         if (ch < 0 || ch > 127) {
276             throw new ParseException("Unexpected character '" + ch + "'", offset);
277         }
278         nextOffset = offset + 1;
279         return ch;
280     }
281
282     private void startSub(final int offset) {
283         subStart = offset;
284     }
285
286     private void append(final char ch) {
287         // We are not reusing string, append the char, otherwise
288         if (subStart == -1) {
289             verifyNotNull(subBuilder).append(ch);
290         }
291     }
292
293     private @NonNull String endSub(final String str, final int end) {
294         return subStart != -1 ? str.substring(subStart, end) : verifyNotNull(subBuilder).toString();
295     }
296
297     private @NonNull StringBuilder flushSub(final String str, final int end) {
298         var sb = subBuilder;
299         if (sb == null) {
300             subBuilder = sb = new StringBuilder();
301         }
302         if (subStart != -1) {
303             sb.setLength(0);
304             sb.append(str, subStart, end);
305             subStart = -1;
306         }
307         return sb;
308     }
309
310     private static byte parsePercent(final String str, final int offset, final int limit) throws ParseException {
311         if (limit - offset < 3) {
312             throw new ParseException("Incomplete escape '" + str.substring(offset, limit) + "'", offset);
313         }
314         return (byte) (parseHex(str, offset + 1) << 4 | parseHex(str, offset + 2));
315     }
316
317     // FIXME: Replace with HexFormat.fromHexDigit(str.charAt(offset)) when we have JDK17+
318     private static int parseHex(final String str, final int offset) throws ParseException {
319         final char ch = str.charAt(offset);
320         if (ch >= '0' && ch <= '9') {
321             return ch - '0';
322         }
323
324         final int zero;
325         if (ch >= 'a' && ch <= 'f') {
326             zero = 'a';
327         } else if (ch >= 'A' && ch <= 'F') {
328             zero = 'A';
329         } else {
330             throw new ParseException("Invalid escape character '" + ch + "'", offset);
331         }
332
333         return ch - zero + 10;
334     }
335 }