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> — 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> — 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] — 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}] — 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}