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()} &mdash; typically a
032 * single per-process instance &mdash; and gets two endpoints for free:
033 * <ul class='spaced-list'>
034 *    <li><b>{@code GET /juneau-jobs/{jobId}/stream}</b> &mdash; 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> &mdash; 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 &mdash; an {@value AsyncJobRegistry#CAPABILITY_BITS}-bit
049 * {@link java.security.SecureRandom} capability &mdash; 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] &mdash; 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] &mdash; 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}