001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.bus;
018
019import java.io.*;
020
021import org.apache.juneau.http.*;
022import org.apache.juneau.marshall.collections.*;
023import org.apache.juneau.marshall.json.*;
024import org.apache.juneau.rest.server.*;
025
026/**
027 * Exposes the bus's session POST and SSE stream on a REST resource.
028 *
029 * <ul>
030 *    <li><b>{@code POST <servlet>/juneau-bus/session}</b> &mdash; opens a session and returns its grant.  This is the
031 *       protocol's only state-changing request, so every guard the application has applies to it.
032 *    <li><b>{@code GET <servlet>/juneau-bus/stream/{sessionId}}</b> &mdash; a held-open {@code text/event-stream} of bus
033 *       frames, each as {@code event: bus}.  Gated by the capability in the path; an unknown or expired id, or another
034 *       principal, is a {@code 404}.
035 * </ul>
036 *
037 * <p>
038 * When {@link BusPolicy#boundary()} is set, both endpoints run its {@code check}: the full write check on the POST,
039 * and on the GET whatever the boundary applies to a read (a {@code LoopbackBoundary}'s Host check, a
040 * {@code CsrfBoundary}'s allowed hosts).  The method judged is the one the request claims, so a GET carrying a
041 * {@code method} parameter or an override header that claims a write gets the write check.
042 *
043 * <h5 class='section'>Example:</h5>
044 * <p class='bjava'>
045 *   <ja>@Rest</ja>(path=<js>"/ops/jobs"</js>)
046 *   <jk>public class</jk> JobsRest <jk>extends</jk> BasicRestServlet <jk>implements</jk> AsyncJobsMixin, BusEventsMixin {
047 *     <jk>static final</jk> ServerBus <jsf>BUS</jsf> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>()
048 *       .downstream(<js>"ops.jobs"</js>, <jk>true</jk>).build());
049 *     <ja>@Override</ja> <jk>public</jk> ServerBus serverBus() { <jk>return</jk> <jsf>BUS</jsf>; }
050 *     ...
051 *   }
052 * </p>
053 *
054 * <h5 class='section'>Example:</h5>
055 * <p class='bjava'>
056 *   <jc>// An application with its own CSRF guard puts it in front of the session POST.
057 *   // Re-declaring @RestPost here is optional and registers the route once.</jc>
058 *   <ja>@Override</ja> <ja>@RestPost</ja>(path=<jsf>SESSION_PATH</jsf>)
059 *   <jk>public</jk> JsonMap openBusSession(RestRequest <jv>req</jv>, RestResponse <jv>res</jv>) {
060 *     requireCsrfToken(<jv>req</jv>);
061 *     <jk>return</jk> BusEventsMixin.<jk>super</jk>.openBusSession(<jv>req</jv>, <jv>res</jv>);
062 *   }
063 * </p>
064 *
065 * @since 10.0.0
066 */
067public interface BusEventsMixin {
068
069   /** The servlet-relative prefix of both endpoints. */
070   String BUS_PREFIX = "/juneau-bus";
071
072   /** The session POST path. */
073   String SESSION_PATH = BUS_PREFIX + "/session";
074
075   /** The SSE stream path. */
076   String STREAM_PATH = BUS_PREFIX + "/stream/{sessionId}";
077
078   /**
079    * The bus this resource serves.
080    *
081    * @return The bus.  Typically one per application, shared with the code that publishes.
082    */
083   ServerBus serverBus();
084
085   /**
086    * [POST /juneau-bus/session] &mdash; opens a bus session.
087    *
088    * <p>
089    * Body (at most 64 KB; a larger body is a 400): {@code {"v":1, "bridge":"ops", "transport":"sse"|"websocket", "downstream":[...], "upstream":[...]}}.
090    * Returns the grant ({@code sessionId}, {@code heartbeatMs}, {@code graceMs}, {@code downstream} with each topic's
091    * {@code retain}, {@code upstream} and {@code paths}).  A refusal sets its status (400, 403, 429 with
092    * {@code Retry-After}, or 501) and returns {@code {"code":..., "message":..., "denied":[...]}}.
093    *
094    * @param req The REST request.
095    * @param res The REST response.
096    * @return The grant, or the refusal body.
097    */
098   @SuppressWarnings({
099      "resource" // False positive: serverBus() returns the shared AutoCloseable bus, which this method borrows but does not own.
100   })
101   @RestPost(path=SESSION_PATH, summary="Open a message-bus session", swagger=@OpSwagger(ignore=true))
102   default JsonMap openBusSession(RestRequest req, RestResponse res) {
103      var bus = serverBus();
104      var hreq = req.getHttpServletRequest();
105      var boundary = bus.policy().boundary();
106      if (boundary != null) {
107         var r = boundary.check(hreq);
108         if (! r.isAllowed()) {
109            res.setStatus(r.status());
110            return JsonMap.of("code", "bus:refused", "message", r.message());
111         }
112      }
113      try {
114         var session = bus.openSession(hreq, sessionBody(req));
115         return bus.grant(session, servletBase(req) + BUS_PREFIX, req.getContextPath());
116      } catch (BusRefusal e) {
117         res.setStatus(e.status());
118         e.retryAfterSeconds().ifPresent(x -> res.setHeader("Retry-After", String.valueOf(x)));
119         return e.toJson();
120      }
121   }
122
123   /**
124    * [GET /juneau-bus/stream/{sessionId}] &mdash; streams the session's frames as Server-Sent Events.
125    *
126    * <p>
127    * Sends the resync, then live frames, until the client goes away or the bus closes the connection (bus closed,
128    * session expired, replaced by another connection, or too slow).  Each frame is one {@code event: bus} with the
129    * frame as {@code data} and its {@code seq}, when it has one, as {@code id}.
130    *
131    * <p>
132    * The stream URL contains the session capability, so an application must not log request URIs for
133    * {@code /juneau-bus/stream/*}.  Juneau's own debug pipeline logs the request path of every completed call when the
134    * resolved logger is loggable at {@code INFO} or finer, and {@code RestSession} logs it at {@code FINE} when a
135    * client disconnects; keep those loggers coarser than {@code INFO} for resources that serve the bus.
136    *
137    * @param id The session's capability id.
138    * @param req The REST request.
139    * @param res The REST response.
140    * @throws IOException If the stream could not be written.
141    */
142   @SuppressWarnings({
143      "resource" // False positive: serverBus() returns the shared AutoCloseable bus (not owned here) and the fluent sse.sendEvent/flush calls return the same 'sse' already managed by the try-with-resources.
144   })
145   @RestGet(path=STREAM_PATH, summary="Message-bus SSE stream", swagger=@OpSwagger(ignore=true))
146   default void streamBus(@Path("sessionId") String id, RestRequest req, RestResponse res) throws IOException {
147      var bus = serverBus();
148      var hreq = req.getHttpServletRequest();
149      var boundary = bus.policy().boundary();
150      if (boundary != null) {
151         var r = boundary.check(hreq);
152         if (! r.isAllowed()) {
153            res.setStatus(r.status());
154            return;
155         }
156      }
157      var session = bus.session(id, hreq.getUserPrincipal()).orElse(null);
158      if (session == null) {
159         res.setStatus(404);
160         return;
161      }
162      var wait = bus.policy().heartbeat();
163      var sink = new SseFrameSink(bus.subscribeSse(session), bus.policy().maxQueuedFrames());
164      try (var sse = res.sse()) {
165         var writer = res.getNegotiatedWriter();
166         bus.connect(session, sink);
167         BusStream.pump(sink, e -> sse.sendEvent(e).flush(), writer::checkError, wait);
168      } catch (InterruptedException e) {
169         Thread.currentThread().interrupt();
170      } finally {
171         bus.disconnect(session, sink);
172         sink.release();
173      }
174   }
175
176   /** Parses the POST body; anything but a JSON object, or a body over 64 KB, is a 400. */
177   private static JsonMap sessionBody(RestRequest req) throws BusRefusal {
178      String text;
179      try {
180         text = BusStream.readCapped(req.getInputStream());
181      } catch (IOException e) {
182         throw BusRefusal.badRequest("the session request could not be read");
183      }
184      try {
185         return text.isBlank() ? null : JsonMap.ofString(text, JsonParser.DEFAULT);
186      } catch (RuntimeException e) {
187         throw BusRefusal.badRequest("the session request must be a JSON object");
188      }
189   }
190
191   /** The root-relative servlet path (context plus servlet) without a trailing slash. */
192   private static String servletBase(RestRequest req) {
193      return BusStream.trimBase(req.getUriContext().getRootRelativeServletPath());
194   }
195}