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.views; 018 019import java.io.*; 020import java.time.*; 021 022import org.apache.juneau.http.*; 023import org.apache.juneau.marshall.marshaller.*; 024import org.apache.juneau.marshall.sse.*; 025import org.apache.juneau.rest.server.*; 026 027/** 028 * Mixin interface that exposes the async-job SSE progress stream and cancel endpoint on any Juneau REST resource. 029 * 030 * <p> 031 * An implementing resource supplies its {@link AsyncJobRegistry} via {@link #asyncJobRegistry()} — typically a 032 * single per-process instance — and gets two endpoints for free: 033 * <ul class='spaced-list'> 034 * <li><b>{@code GET /juneau-jobs/{jobId}/stream}</b> — a held-open {@code text/event-stream} of the job's 035 * progress, terminated by one {@code result} event carrying the terminal {@link ActionResult}. Each 036 * {@code progress} event carries an SSE id; a fresh attach is live-only, and a {@code Last-Event-ID} header or 037 * {@code lastEventId} query parameter resumes from a position (see {@link #streamJob(String, RestRequest, RestResponse)}). 038 * <li><b>{@code POST /juneau-jobs/{jobId}/cancel}</b> — cancels the job and returns its terminal 039 * {@link ActionResult} (a {@code cancelled} / {@code cancelled-after-effect} outcome). 040 * </ul> 041 * 042 * <h5 class='section'>Authorization: the stream URL is the capability</h5> 043 * <p> 044 * The stream is a held-open <b>GET</b>, so {@link org.apache.juneau.rest.server.filter.LoopbackBoundary} applies only 045 * its <b>Host</b> check (DNS-rebinding stays covered on every request); the Origin/CSRF/JSON checks apply to non-safe 046 * methods only. A browser {@code EventSource} cannot set an {@code X-Csrf-Token} header and cookies are unsound on a 047 * loopback port, so CSRF-on-GET is not an available control. Access is therefore gated by the <b>unguessability of 048 * the {@code jobId}</b> path segment — an {@value AsyncJobRegistry#CAPABILITY_BITS}-bit 049 * {@link java.security.SecureRandom} capability — and nothing else. An unknown id answers {@code 404} (a miss 050 * is indistinguishable, so the id space cannot be enumerated); a job already at its subscriber cap answers 051 * {@code 429}. A resume value grants nothing: it only picks a position inside a job the caller already holds the 052 * capability for, and it is checked only after the {@code 404}, so a bad value cannot reveal whether a job exists. 053 * 054 * <h5 class='section'>Server-side hard limits</h5> 055 * <p> 056 * Every limit is enforced in {@link AsyncJob} / {@link AsyncJobRegistry}, not here and not on the client: the hard 057 * timeout, the per-job output cap, the concurrent-job cap and the per-job subscriber cap. The stream loop simply 058 * drains the job until it is terminal, which the timeout guarantees will happen. 059 * 060 * <h5 class='section'>Example:</h5> 061 * <p class='bjava'> 062 * <ja>@Rest</ja> 063 * <jk>public class</jk> MyResource <jk>extends</jk> BasicRestServlet <jk>implements</jk> AsyncJobsMixin { 064 * <jk>private final</jk> AsyncJobRegistry <jf>jobs</jf> = <jk>new</jk> AsyncJobRegistry(); 065 * <ja>@Override</ja> <jk>public</jk> AsyncJobRegistry asyncJobRegistry() { <jk>return</jk> <jf>jobs</jf>; } 066 * } 067 * </p> 068 * 069 * <h5 class='section'>See Also:</h5> 070 * <ul> 071 * <li class='jc'>{@link AsyncJobRegistry} 072 * <li class='jc'>{@link AsyncJob} 073 * <li class='jc'>{@link AsyncJobRef} 074 * </ul> 075 * 076 * @since 10.0.0 077 */ 078@SuppressWarnings({ 079 "resource" // asyncJobRegistry() returns an AutoCloseable registry owned and closed by the implementing resource, not by these endpoints 080}) 081public interface AsyncJobsMixin { 082 083 /** The URL path prefix for the async-job endpoints (relative to the host mount). */ 084 String JOBS_PREFIX = "/juneau-jobs"; 085 086 /** The URL path template of the SSE progress stream. */ 087 String STREAM_PATH = JOBS_PREFIX + "/{jobId}/stream"; 088 089 /** The URL path template of the cancel endpoint. */ 090 String CANCEL_PATH = JOBS_PREFIX + "/{jobId}/cancel"; 091 092 /** How long the stream loop blocks per poll while a job is still running (a liveness tick, not a timeout). */ 093 Duration STREAM_POLL_INTERVAL = Duration.ofMillis(250); 094 095 /** 096 * Returns the {@link AsyncJobRegistry} backing this resource's async jobs. 097 * 098 * @return The registry. Never <jk>null</jk>. 099 */ 100 AsyncJobRegistry asyncJobRegistry(); 101 102 /** 103 * [GET /juneau-jobs/{jobId}/stream] — stream a job's progress as Server-Sent Events. 104 * 105 * <p> 106 * Streams progress as {@code progress} events, then sends exactly one {@code result} event carrying the terminal 107 * {@link ActionResult} as JSON and closes. The progress event taken from the job's buffered event {@code i} is 108 * always sent with SSE id {@code i+1}; the {@code result} event has no id, so a browser's last-event-ID stays at 109 * the last progress event. 110 * 111 * <p> 112 * The start position comes from a resume value, in this order: 113 * <ol class='spaced-list'> 114 * <li>The {@code Last-Event-ID} header, if non-empty after trimming (a browser's own reconnect re-sends the same 115 * URL, query included, and adds this header). 116 * <li>Otherwise the {@code lastEventId} query parameter, if non-empty after trimming (an explicit opt-in to the 117 * buffered history; {@code lastEventId=0} replays everything). 118 * <li>Otherwise none: a fresh attach is <b>live-only</b> and sends only progress produced after it attached. 119 * </ol> 120 * A value {@code k} resumes with buffered event {@code k} (id {@code k+1}). A malformed value (anything but 1 to 10 121 * ASCII digits after trimming SP/HTAB) or one past the end of the buffer is a {@code 400}. An ended job still sends 122 * its {@code result} on every attach. 123 * 124 * <p> 125 * Access is gated solely by the unguessable {@code jobId} (see the interface javadoc). The checks run in this 126 * order: an unknown id is a {@code 404}; a bad resume value is a {@code 400}; a job already at its subscriber cap is 127 * a {@code 429}. The cursor is fixed before the subscriber slot is taken, so a {@code 400} never holds a slot. 128 * 129 * @param jobId The job's unguessable capability id. 130 * @param req The REST request (for the resume value). 131 * @param res The REST response. 132 * @throws IOException If the stream could not be written. 133 */ 134 @RestGet(path=STREAM_PATH, summary="Async job SSE progress stream", swagger=@OpSwagger(ignore=true)) 135 default void streamJob(@Path("jobId") String jobId, RestRequest req, RestResponse res) throws IOException { 136 var job = asyncJobRegistry().get(jobId).orElse(null); 137 if (job == null) { 138 res.setStatus(404); 139 return; 140 } 141 int cursor; 142 try { 143 cursor = job.resumeCursor(resumeValue(req)); 144 } catch (IllegalArgumentException ignored) { 145 res.setStatus(400); 146 return; 147 } 148 if (! job.acquireSubscriber()) { 149 res.setStatus(429); 150 return; 151 } 152 try (var sse = res.sse()) { 153 while (true) { 154 var update = job.awaitUpdate(cursor, STREAM_POLL_INTERVAL); 155 for (var event : update.events()) { 156 cursor++; // events[i] is sent with id i+1, so after the increment the id is the new cursor. 157 sse.sendEvent(new SseEvent("progress", event).setId(Integer.toString(cursor))); 158 } 159 sse.flush(); 160 if (update.result() != null) { 161 sse.sendEvent("result", Json.of(update.result())); 162 sse.flush(); 163 return; 164 } 165 } 166 } finally { 167 job.releaseSubscriber(); 168 } 169 } 170 171 /** 172 * Picks the raw resume value for {@link #streamJob(String, RestRequest, RestResponse)}: a non-empty (after 173 * trimming) {@code Last-Event-ID} header, else a non-empty {@code lastEventId} query parameter, else <jk>null</jk>. 174 * 175 * @param req The REST request. 176 * @return The raw value, or <jk>null</jk> if neither source supplied one. 177 */ 178 private static String resumeValue(RestRequest req) { 179 var header = req.getHeaderParam("Last-Event-ID").orElse(null); 180 if (header != null && ! AsyncJob.trimOws(header).isEmpty()) 181 return header; 182 var query = req.getQueryParam("lastEventId").orElse(null); 183 if (query != null && ! AsyncJob.trimOws(query).isEmpty()) 184 return query; 185 return null; 186 } 187 188 /** 189 * [POST /juneau-jobs/{jobId}/cancel] — cancel a job and return its terminal result. 190 * 191 * <p> 192 * A non-safe POST, so the boundary applies its full Origin/CSRF/JSON checks. Cancellation is enforced 193 * server-side (a client cannot be trusted to stop the work); the returned outcome distinguishes {@code cancelled} 194 * from {@code cancelled-after-effect}. An unknown id is a {@code 404} carrying a named refusal. 195 * 196 * @param jobId The job's unguessable capability id. 197 * @param res The REST response. 198 * @return The job's terminal {@link ActionResult}. 199 */ 200 @RestPost(path=CANCEL_PATH, summary="Cancel an async job", swagger=@OpSwagger(ignore=true)) 201 default ActionResult cancelJob(@Path("jobId") String jobId, RestResponse res) { 202 var job = asyncJobRegistry().get(jobId).orElse(null); 203 if (job == null) { 204 res.setStatus(404); 205 return ActionResult.refusal("app:unknown-job"); 206 } 207 job.cancel(); 208 return job.result(); 209 } 210 211 /** 212 * Builds the servlet-relative stream path for a job id. 213 * 214 * @param jobId The job's capability id. 215 * @return The stream path, e.g. {@code /juneau-jobs/<id>/stream}. 216 */ 217 static String streamPath(String jobId) { 218 return JOBS_PREFIX + "/" + jobId + "/stream"; 219 } 220 221 /** 222 * Builds the servlet-relative cancel path for a job id. 223 * 224 * @param jobId The job's capability id. 225 * @return The cancel path, e.g. {@code /juneau-jobs/<id>/cancel}. 226 */ 227 static String cancelPath(String jobId) { 228 return JOBS_PREFIX + "/" + jobId + "/cancel"; 229 } 230}