beea59a3abefa7eb60fcba160bbbb4481eb734aa
[netconf.git] /
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.netconf.transport.http;
9
10 import static java.util.Objects.requireNonNull;
11
12 import com.google.common.annotations.Beta;
13 import com.google.common.annotations.VisibleForTesting;
14 import io.netty.buffer.ByteBuf;
15 import io.netty.channel.ChannelHandlerContext;
16 import io.netty.channel.SimpleChannelInboundHandler;
17 import io.netty.handler.codec.http.DefaultFullHttpResponse;
18 import io.netty.handler.codec.http.FullHttpRequest;
19 import io.netty.handler.codec.http.FullHttpResponse;
20 import io.netty.handler.codec.http.HttpHeaderNames;
21 import io.netty.handler.codec.http.HttpHeaders;
22 import io.netty.handler.codec.http.HttpMethod;
23 import io.netty.handler.codec.http.HttpResponseStatus;
24 import io.netty.handler.codec.http.HttpUtil;
25 import io.netty.handler.codec.http.HttpVersion;
26 import io.netty.handler.codec.http2.HttpConversionUtil.ExtensionHeaderNames;
27 import io.netty.util.AsciiString;
28 import java.net.URI;
29 import java.net.URISyntaxException;
30 import java.util.Arrays;
31 import java.util.Map;
32 import java.util.function.Function;
33 import java.util.stream.Collectors;
34 import org.eclipse.jdt.annotation.NonNull;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.opendaylight.netconf.transport.api.TransportChannelListener;
38 import org.slf4j.Logger;
39 import org.slf4j.LoggerFactory;
40
41 /**
42  * Abstract base class for handling HTTP events on a {@link HTTPServer}'s channel pipeline. Users will typically add
43  * a subclass onto the corresponding {@link HTTPTransportChannel} when notified through
44  * {@link TransportChannelListener}.
45  *
46  * <p>Incoming requests are processed in four distinct stages:
47  * <ol>
48  *   <li>request method binding, performed on the Netty thread via {@link #implementationOf(HttpMethod)}</li>
49  *   <li>request path and header binding, performed on the Netty thread via
50  *       {@link #prepareRequest(ImplementedMethod, URI, HttpHeaders)}</li>
51  *   <li>request execution, performed in a dedicated thread, via
52  *       {@link PendingRequest#execute(PendingRequestListener, java.io.InputStream)}</li>
53  *   <li>response execution, performed in another dedicated thread</li>
54  * </ol>
55  * This split is done to off-load request and response body construction, so that it can result in a number of messages
56  * being sent down the Netty pipeline. That aspect is important when producing chunked-encoded response from a state
57  * snapshot -- which is the typical use case.
58  */
59 @Beta
60 public abstract class HTTPServerSession extends SimpleChannelInboundHandler<FullHttpRequest> {
61     private static final Logger LOG = LoggerFactory.getLogger(HTTPServerSession.class);
62     private static final AsciiString STREAM_ID = ExtensionHeaderNames.STREAM_ID.text();
63     private static final Map<HttpMethod, ImplementedMethod> ALL_METHODS = Arrays.stream(ImplementedMethod.values())
64         .collect(Collectors.toUnmodifiableMap(ImplementedMethod::httpMethod, Function.identity()));
65
66     // FIXME: HTTP/1.1 and HTTP/2 behave differently w.r.t. incoming requests and their servicing:
67     //  1. HTTP/1.1 uses pipelining, therefore:
68     //     - we cannot halt incoming pipeline
69     //     - we may run request prepare() while a request is executing in the background, but
70     //     - we MUST send responses out in the same order we have received them
71     //     - SSE GET turns the session into a sender, i.e. no new requests can be processed
72     //  2. HTTP/2 uses concurrent execution, therefore
73     //     - we need to track which streams are alive and support terminating pruning requests when client resets
74     //       a stream
75     //     - SSE is nothing special
76     //     - we have Http2Settings, which has framesize -- which we should use when streaming responses
77     //  We support HTTP/1.1 -> HTTP/2 upgrade for the first request only -- hence we know before processing the first
78     //  result which mode of operation is effective. We probably need to have two subclasses of this thing, with
79     //  HTTP/1.1 and HTTP/2 specializations.
80
81     // FIXME: this heavily depends on the object model and is tied to HTTPServer using aggregators, so perhaps we should
82     //        reconsider the design
83
84     private final HTTPScheme scheme;
85
86     // Only valid when the session is attached to a Channel
87     private ServerRequestExecutor executor;
88
89     protected HTTPServerSession(final HTTPScheme scheme) {
90         super(FullHttpRequest.class, false);
91         this.scheme = requireNonNull(scheme);
92     }
93
94     @Override
95     public final void handlerAdded(final ChannelHandlerContext ctx) {
96         final var channel = ctx.channel();
97         executor = new ServerRequestExecutor(channel.remoteAddress().toString());
98         LOG.debug("Threadpools for {} started", channel);
99     }
100
101     @Override
102     public void channelInactive(final ChannelHandlerContext ctx) throws Exception {
103         shutdownTreadpools(ctx);
104         super.channelInactive(ctx);
105     }
106
107     @Override
108     public final void handlerRemoved(final ChannelHandlerContext ctx) {
109         shutdownTreadpools(ctx);
110     }
111
112     private void shutdownTreadpools(final ChannelHandlerContext ctx) {
113         executor.shutdown();
114         LOG.debug("Threadpools for {} shut down", ctx.channel());
115     }
116
117     @Override
118     protected final void channelRead0(final ChannelHandlerContext ctx, final FullHttpRequest msg) {
119         final var headers = msg.headers();
120         // non-null indicates HTTP/2 request, which we need to propagate to any response
121         final var streamId = headers.getInt(STREAM_ID);
122         final var version = msg.protocolVersion();
123
124         // first things first:
125         // - HTTP semantics: we MUST have the equivalent of a Host header, as per
126         //   https://www.rfc-editor.org/rfc/rfc9110#section-7.2
127         // - HTTP/1.1 protocol: it is a 400 Bad Request, as per
128         //   https://www.rfc-editor.org/rfc/rfc9112#section-3.2, if
129         //   - there are multiple values
130         //   - the value is invalid
131         final var hostItr = headers.valueStringIterator(HttpHeaderNames.HOST);
132         if (!hostItr.hasNext()) {
133             LOG.debug("No Host header in request {}", msg);
134             msg.release();
135             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST));
136             return;
137         }
138         final var host = hostItr.next();
139         if (hostItr.hasNext()) {
140             LOG.debug("Multiple Host header values in request {}", msg);
141             msg.release();
142             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST));
143             return;
144         }
145         final URI hostUri;
146         try {
147             hostUri = scheme.hostUriOf(host);
148         } catch (URISyntaxException e) {
149             LOG.debug("Invalid Host header value '{}'", host, e);
150             msg.release();
151             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST));
152             return;
153         }
154
155         // next up:
156         // - check if we implement requested method
157         // - we do NOT implement CONNECT method, which is the only valid use of URIs in authority-form
158         final var nettyMethod = msg.method();
159         final var method = implementationOf(nettyMethod);
160         if (method == null) {
161             LOG.debug("Method {} not implemented", nettyMethod);
162             msg.release();
163             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.NOT_IMPLEMENTED));
164             return;
165         }
166
167         // next possibility: asterisk-form, as per https://www.rfc-editor.org/rfc/rfc9112#section-3.2.4
168         // it is only applicable to server-wide OPTIONS https://www.rfc-editor.org/rfc/rfc9110#section-9.3.7, which
169         // should result in Allow: listing the contents of IMPLEMENTED_METHODS
170         final var uri = msg.uri();
171         if (HttpUtil.isAsteriskForm(uri)) {
172             msg.release();
173             respond(ctx, streamId, asteriskRequest(version, method));
174             return;
175         }
176
177         // we are down to three possibilities:
178         // - origin-form, as per https://www.rfc-editor.org/rfc/rfc9112#section-3.2.1
179         // - absolute-form, as per https://www.rfc-editor.org/rfc/rfc9112#section-3.2.2
180         // - authority-form, as per https://www.rfc-editor.org/rfc/rfc9112#section-3.2.3
181         // BUT but we do not implement the CONNECT method, so let's enlist URI's services first
182         final URI requestUri;
183         try {
184             requestUri = new URI(uri);
185         } catch (URISyntaxException e) {
186             LOG.debug("Invalid request-target '{}'", uri, e);
187             msg.release();
188             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST));
189             return;
190         }
191
192         // as per https://www.rfc-editor.org/rfc/rfc9112#section-3.3
193         final URI targetUri;
194         if (requestUri.isAbsolute()) {
195             // absolute-form is the Target URI
196             targetUri = requestUri;
197         } else if (HttpUtil.isOriginForm(requestUri)) {
198             // origin-form needs to be combined with Host header
199             targetUri = hostUri.resolve(requestUri);
200         } else {
201             LOG.debug("Unsupported request-target '{}'", requestUri);
202             msg.release();
203             respond(ctx, streamId, new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST));
204             return;
205         }
206
207         switch (prepareRequest(method, targetUri, msg.headers())) {
208             case CompletedRequest completed -> {
209                 msg.release();
210                 LOG.debug("Immediate response to {} {}", method, targetUri);
211                 executor.respond(ctx, streamId, version, completed.asResponse());
212             }
213             case PendingRequest<?> pending -> {
214                 LOG.debug("Scheduling execution of {} {}", method, targetUri);
215                 executor.executeRequest(ctx, version, streamId, pending, msg.content());
216             }
217         }
218     }
219
220     @VisibleForTesting
221     static final @NonNull FullHttpResponse asteriskRequest(final HttpVersion version, final ImplementedMethod method) {
222         if (method == ImplementedMethod.OPTIONS) {
223             final var response = new DefaultFullHttpResponse(version, HttpResponseStatus.OK);
224             response.headers().set(HttpHeaderNames.ALLOW, "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT");
225             return response;
226         }
227         LOG.debug("Invalid use of '*' with method {}", method);
228         return new DefaultFullHttpResponse(version, HttpResponseStatus.BAD_REQUEST);
229     }
230
231     /**
232      * Check whether this session implements a particular HTTP method. Default implementation supports all
233      * {@link ImplementedMethod}s.
234      *
235      * @param method an HTTP method
236      * @return an {@link ImplementedMethod}, or {@code null} if the method is not implemented
237      */
238     protected @Nullable ImplementedMethod implementationOf(final @NonNull HttpMethod method) {
239         return ALL_METHODS.get(method);
240     }
241
242     /**
243      * Prepare an incoming HTTP request. The first two arguments are provided as context, which should be reflected back
244      * to {@link #respond(ChannelHandlerContext, Integer, FullHttpResponse)} when reponding to the request.
245      *
246      * <p>The ownership of request body is transferred to the implementation of this method. It is its responsibility to
247      * {@link ByteBuf#release()} it when no longer needed.
248      *
249      * @param method {@link ImplementedMethod} being requested
250      * @param targetUri URI of the target resource
251      * @param headers request {@link HttpHeaders}
252      */
253     @NonNullByDefault
254     protected abstract PreparedRequest prepareRequest(ImplementedMethod method, URI targetUri, HttpHeaders headers);
255
256     @NonNullByDefault
257     static final void respond(final ChannelHandlerContext ctx, final @Nullable Integer streamId,
258             final FullHttpResponse response) {
259         requireNonNull(response);
260         if (streamId != null) {
261             response.headers().setInt(STREAM_ID, streamId);
262         }
263         ctx.writeAndFlush(response);
264     }
265 }