Skip to content

Commit 4e07bb8

Browse files
committed
feat: Implement SQL on FHIR unify-async 303 redirect pattern
Add opt-in support for 303 See Other redirects on completed async jobs, following the SQL on FHIR unify-async specification. When enabled via @AsyncSupported(redirectOnComplete = true), completed jobs return a 303 redirect to the new $job-result endpoint instead of inline results. Changes: - Add redirectOnComplete attribute to @AsyncSupported annotation - Add redirectOnComplete field to Job class - Update AsyncAspect to capture redirect flag from annotation - Update JobProvider to return 303 with Location header when enabled - Create new JobResultProvider for $job-result endpoint - Enable redirect pattern on $viewdefinition-export operation Bulk export operations continue using the legacy 200 OK inline pattern.
1 parent 7258f59 commit 4e07bb8

File tree

10 files changed

+721
-12
lines changed

10 files changed

+721
-12
lines changed

server/src/main/java/au/csiro/pathling/FhirServer.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import static au.csiro.pathling.utilities.Preconditions.checkPresent;
2121

2222
import au.csiro.pathling.async.JobProvider;
23+
import au.csiro.pathling.async.JobResultProvider;
2324
import au.csiro.pathling.cache.EntityTagInterceptor;
2425
import au.csiro.pathling.config.OperationConfiguration;
2526
import au.csiro.pathling.config.ServerConfiguration;
@@ -127,6 +128,8 @@ public class FhirServer extends RestfulServer {
127128

128129
@Nonnull private final transient Optional<JobProvider> jobProvider;
129130

131+
@Nonnull private final transient Optional<JobResultProvider> jobResultProvider;
132+
130133
@Nonnull private final transient SystemExportProvider exportProvider;
131134

132135
@Nonnull private final transient ExportResultProvider exportResultProvider;
@@ -178,6 +181,7 @@ public class FhirServer extends RestfulServer {
178181
* @param configuration the server configuration
179182
* @param oidcConfiguration the optional OIDC configuration
180183
* @param jobProvider the optional job provider
184+
* @param jobResultProvider the optional job result provider
181185
* @param exportProvider the export provider
182186
* @param exportResultProvider the export result provider
183187
* @param patientExportProvider the patient export provider
@@ -205,6 +209,7 @@ public FhirServer(
205209
@Nonnull final ServerConfiguration configuration,
206210
@Nonnull final Optional<OidcConfiguration> oidcConfiguration,
207211
@Nonnull final Optional<JobProvider> jobProvider,
212+
@Nonnull final Optional<JobResultProvider> jobResultProvider,
208213
@Nonnull final SystemExportProvider exportProvider,
209214
@Nonnull final ExportResultProvider exportResultProvider,
210215
@Nonnull final PatientExportProvider patientExportProvider,
@@ -232,6 +237,7 @@ public FhirServer(
232237
this.configuration = configuration;
233238
this.oidcConfiguration = oidcConfiguration;
234239
this.jobProvider = jobProvider;
240+
this.jobResultProvider = jobResultProvider;
235241
this.exportProvider = exportProvider;
236242
this.exportResultProvider = exportResultProvider;
237243
this.patientExportProvider = patientExportProvider;
@@ -272,8 +278,9 @@ protected void initialize() throws ServletException {
272278
// Get operation configuration.
273279
final OperationConfiguration ops = configuration.getOperations();
274280

275-
// Register job provider, if async is enabled.
281+
// Register job providers, if async is enabled.
276282
jobProvider.ifPresent(this::registerProvider);
283+
jobResultProvider.ifPresent(this::registerProvider);
277284

278285
// Register export providers based on configuration.
279286
if (ops.isExportEnabled()) {

server/src/main/java/au/csiro/pathling/async/AsyncAspect.java

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -130,12 +130,11 @@ protected IBaseResource maybeExecuteAsynchronously(
130130
log.info("Asynchronous processing requested");
131131

132132
if (result == null) {
133-
// the class containing the async annotation on a method does not implement
134-
// PreAsyncValidation
135-
// set some values to prevent NPEs
133+
// The class containing the async annotation on a method does not implement
134+
// PreAsyncValidation. Set some values to prevent NPEs.
136135
result = new PreAsyncValidationResult<>(new Object(), List.of());
137136
}
138-
processRequestAsynchronously(joinPoint, requestDetails, result, spark);
137+
processRequestAsynchronously(joinPoint, requestDetails, result, spark, asyncSupported);
139138
throw new ProcessingNotCompletedException("Accepted", buildOperationOutcome(result));
140139
} else {
141140
return (IBaseResource) joinPoint.proceed();
@@ -146,7 +145,8 @@ private void processRequestAsynchronously(
146145
@Nonnull final ProceedingJoinPoint joinPoint,
147146
@Nonnull final ServletRequestDetails requestDetails,
148147
@Nonnull final PreAsyncValidationResult<?> preAsyncValidationResult,
149-
@Nonnull final SparkSession spark) {
148+
@Nonnull final SparkSession spark,
149+
@Nonnull final AsyncSupported asyncSupported) {
150150
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
151151

152152
// Compute operation-specific cache key if the operation provides it.
@@ -217,6 +217,7 @@ private void processRequestAsynchronously(
217217
final Optional<String> ownerId = getCurrentUserId(authentication);
218218
final Job<IBaseResource> newJob = new Job<>(jobId, operation, result, ownerId);
219219
newJob.setPreAsyncValidationResult(preAsyncValidationResult.result());
220+
newJob.setRedirectOnComplete(asyncSupported.redirectOnComplete());
220221
return newJob;
221222
});
222223
final HttpServletResponse response = requestDetails.getServletResponse();

server/src/main/java/au/csiro/pathling/async/AsyncSupported.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,4 +33,13 @@
3333
@Retention(RetentionPolicy.RUNTIME)
3434
@Inherited
3535
@Documented
36-
public @interface AsyncSupported {}
36+
public @interface AsyncSupported {
37+
38+
/**
39+
* When true, completed jobs return 303 See Other with a redirect to the result endpoint, rather
40+
* than returning the result inline. This follows the SQL on FHIR unify-async specification.
41+
*
42+
* @return true if completed jobs should redirect to the result endpoint
43+
*/
44+
boolean redirectOnComplete() default false;
45+
}

server/src/main/java/au/csiro/pathling/async/Job.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ public interface JobTag {}
7171
/** Indicates whether this job has been marked for deletion. */
7272
@Setter private boolean markedAsDeleted;
7373

74+
/**
75+
* When true, completed jobs return 303 See Other with redirect to result endpoint, rather than
76+
* returning the result inline. This follows the SQL on FHIR unify-async specification.
77+
*/
78+
@Setter private boolean redirectOnComplete;
79+
7480
/**
7581
* The last calculated progress percentage. When a job is at 100% that does not always indicate
7682
* that the job is actually finished. Most of the time, this indicates that a new stage has not

server/src/main/java/au/csiro/pathling/async/JobProvider.java

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,7 @@ private IBaseResource handleJobGetRequest(
232232
throw handleCancelledJob();
233233
}
234234
if (job.getResult().isDone()) {
235-
return handleCompletedJob(job, response);
235+
return handleCompletedJob(job, request, response);
236236
}
237237
return handleInProgressJob(request, response, job);
238238
}
@@ -253,21 +253,38 @@ private static ResourceNotFoundException handleCancelledJob() {
253253
}
254254

255255
/**
256-
* Handles a completed job by returning its result or converting exceptions.
256+
* Handles a completed job by returning its result or redirecting to the result endpoint.
257+
*
258+
* <p>If the job has {@code redirectOnComplete} enabled (following the SQL on FHIR unify-async
259+
* specification), returns 303 See Other with a Location header pointing to the result endpoint.
260+
* Otherwise, returns the result inline.
257261
*
258262
* @param job The completed job.
263+
* @param request The HTTP request for building the result URL.
259264
* @param response The HTTP response for applying response modifications.
260-
* @return The job result.
265+
* @return The job result (if not redirecting) or an empty Parameters resource (if redirecting).
261266
* @throws InternalErrorException If the job was interrupted.
262267
*/
263268
@Nonnull
264269
private IBaseResource handleCompletedJob(
265-
@Nonnull final Job<?> job, @Nullable final HttpServletResponse response) {
270+
@Nonnull final Job<?> job,
271+
@Nonnull final HttpServletRequest request,
272+
@Nullable final HttpServletResponse response) {
266273
try {
267274
// Completed responses use TTL-based caching with configured max-age.
268275
if (response != null) {
269276
setAsyncCacheHeaders(response);
270277
}
278+
279+
// If redirect is enabled, return 303 See Other with Location header.
280+
if (job.isRedirectOnComplete() && response != null) {
281+
final String resultUrl = buildResultUrl(request, job.getId());
282+
response.setStatus(HttpServletResponse.SC_SEE_OTHER);
283+
response.setHeader("Location", resultUrl);
284+
return new Parameters();
285+
}
286+
287+
// Otherwise return the result inline (legacy behaviour).
271288
job.getResponseModification().accept(response);
272289
return job.getResult().get();
273290
} catch (final InterruptedException e) {
@@ -278,6 +295,22 @@ private IBaseResource handleCompletedJob(
278295
}
279296
}
280297

298+
/**
299+
* Builds the URL for the job result endpoint. Uses the servlet context path to ensure the URL is
300+
* correctly prefixed (e.g., /fhir/$job-result).
301+
*
302+
* @param request The HTTP request to extract the context path from.
303+
* @param jobId The job ID.
304+
* @return The result URL.
305+
*/
306+
@Nonnull
307+
private static String buildResultUrl(
308+
@Nonnull final HttpServletRequest request, @Nonnull final String jobId) {
309+
// Use the servlet path to get the FHIR server mount point (e.g., "/fhir").
310+
final String servletPath = request.getServletPath();
311+
return servletPath + "/$job-result?id=" + jobId;
312+
}
313+
281314
/**
282315
* Unwraps the cause chain from an ExecutionException. The Future wraps exceptions in
283316
* ExecutionException, and AsyncAspect may wrap them in IllegalStateException.
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/*
2+
* Copyright © 2018-2026 Commonwealth Scientific and Industrial Research
3+
* Organisation (CSIRO) ABN 41 687 119 230.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
18+
package au.csiro.pathling.async;
19+
20+
import static au.csiro.pathling.security.SecurityAspect.checkHasAuthority;
21+
import static au.csiro.pathling.security.SecurityAspect.getCurrentUserId;
22+
23+
import au.csiro.pathling.config.ServerConfiguration;
24+
import au.csiro.pathling.errors.AccessDeniedError;
25+
import au.csiro.pathling.errors.ErrorHandlingInterceptor;
26+
import au.csiro.pathling.errors.ResourceNotFoundError;
27+
import au.csiro.pathling.security.PathlingAuthority;
28+
import ca.uhn.fhir.rest.annotation.Operation;
29+
import ca.uhn.fhir.rest.annotation.OperationParam;
30+
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
31+
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
32+
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
33+
import jakarta.annotation.Nonnull;
34+
import jakarta.annotation.Nullable;
35+
import jakarta.servlet.http.HttpServletRequest;
36+
import jakarta.servlet.http.HttpServletResponse;
37+
import java.util.Optional;
38+
import java.util.concurrent.ExecutionException;
39+
import java.util.regex.Pattern;
40+
import lombok.extern.slf4j.Slf4j;
41+
import org.hl7.fhir.instance.model.api.IBaseResource;
42+
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
43+
import org.springframework.security.core.Authentication;
44+
import org.springframework.security.core.context.SecurityContextHolder;
45+
import org.springframework.stereotype.Component;
46+
47+
/**
48+
* Provides the $job-result operation for retrieving the result of a completed async job. This
49+
* endpoint is used when operations are configured with {@code redirectOnComplete=true}, following
50+
* the SQL on FHIR unify-async specification.
51+
*
52+
* <p>The flow is: 1. Client polls $job endpoint until job completes 2. $job returns 303 See Other
53+
* with Location header pointing to $job-result 3. Client fetches result from $job-result endpoint
54+
*
55+
* @author John Grimes
56+
*/
57+
@Component
58+
@ConditionalOnProperty(prefix = "pathling", name = "async.enabled", havingValue = "true")
59+
@Slf4j
60+
public class JobResultProvider {
61+
62+
private static final Pattern ID_PATTERN =
63+
Pattern.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$");
64+
65+
@Nonnull private final ServerConfiguration configuration;
66+
67+
@Nonnull private final JobRegistry jobRegistry;
68+
69+
/**
70+
* Creates a new JobResultProvider.
71+
*
72+
* @param configuration the server configuration
73+
* @param jobRegistry the job registry
74+
*/
75+
public JobResultProvider(
76+
@Nonnull final ServerConfiguration configuration, @Nonnull final JobRegistry jobRegistry) {
77+
this.configuration = configuration;
78+
this.jobRegistry = jobRegistry;
79+
}
80+
81+
/**
82+
* Retrieves the result of a completed async job.
83+
*
84+
* @param id the job ID
85+
* @param request the HTTP request
86+
* @param response the HTTP response
87+
* @return the job result as a Parameters resource
88+
*/
89+
@SuppressWarnings("unused")
90+
@Operation(name = "$job-result", idempotent = true)
91+
public IBaseResource jobResult(
92+
@Nullable @OperationParam(name = "id") final String id,
93+
@Nonnull final HttpServletRequest request,
94+
@Nullable final HttpServletResponse response) {
95+
log.debug("Received $job-result request with id: {}", id);
96+
97+
final Job<?> job = getJob(id);
98+
99+
if (configuration.getAuth().isEnabled()) {
100+
// Check for the required authority associated with the operation that initiated the job.
101+
checkHasAuthority(PathlingAuthority.operationAccess(job.getOperation()));
102+
// Check that the user requesting the job result is the same user that started the job.
103+
final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
104+
final Optional<String> currentUserId = getCurrentUserId(authentication);
105+
if (!job.getOwnerId().equals(currentUserId)) {
106+
throw new AccessDeniedError("The requested job is not owned by the current user");
107+
}
108+
}
109+
110+
return handleJobResultRequest(job, response);
111+
}
112+
113+
@Nonnull
114+
private Job<?> getJob(@Nullable final String id) {
115+
// Validate that the ID looks reasonable.
116+
if (id == null || !ID_PATTERN.matcher(id).matches()) {
117+
throw new ResourceNotFoundError("Job ID not found");
118+
}
119+
120+
log.debug("Received request for job result: {}", id);
121+
@Nullable final Job<?> job = jobRegistry.get(id);
122+
// Check that the job exists.
123+
if (job == null) {
124+
throw new ResourceNotFoundError("Job ID not found");
125+
}
126+
return job;
127+
}
128+
129+
@Nonnull
130+
private IBaseResource handleJobResultRequest(
131+
@Nonnull final Job<?> job, @Nullable final HttpServletResponse response) {
132+
// Handle cancelled jobs.
133+
if (job.getResult().isCancelled()) {
134+
throw new ResourceNotFoundException(
135+
"A DELETE request cancelled this job or deleted all files associated with this job.");
136+
}
137+
138+
// Verify the job is complete.
139+
if (!job.getResult().isDone()) {
140+
throw new InvalidRequestException(
141+
"Job is not yet complete. Poll the $job endpoint to check status.");
142+
}
143+
144+
// Set cache headers.
145+
if (response != null) {
146+
final int maxAge = configuration.getAsync().getCacheMaxAge();
147+
response.setHeader("Cache-Control", "max-age=" + maxAge);
148+
}
149+
150+
// Apply any response modifications set by the operation (e.g., Expires header).
151+
job.getResponseModification().accept(response);
152+
153+
// Return the result.
154+
try {
155+
return job.getResult().get();
156+
} catch (final InterruptedException e) {
157+
Thread.currentThread().interrupt();
158+
throw new InternalErrorException("Job was interrupted", e);
159+
} catch (final ExecutionException e) {
160+
throw ErrorHandlingInterceptor.convertError(unwrapExecutionException(e));
161+
}
162+
}
163+
164+
/**
165+
* Unwraps the cause chain from an ExecutionException. The Future wraps exceptions in
166+
* ExecutionException, and AsyncAspect may wrap them in IllegalStateException.
167+
*
168+
* @param e The ExecutionException to unwrap.
169+
* @return The root cause or the original exception.
170+
*/
171+
@Nonnull
172+
private static Throwable unwrapExecutionException(@Nonnull final ExecutionException e) {
173+
Throwable cause = e.getCause();
174+
if (cause != null && cause.getCause() != null) {
175+
cause = cause.getCause();
176+
}
177+
return cause != null ? cause : e;
178+
}
179+
}

server/src/main/java/au/csiro/pathling/operations/view/ViewDefinitionExportProvider.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ public ViewDefinitionExportProvider(
150150
@SuppressWarnings({"unused", "java:S107"})
151151
@Operation(name = "viewdefinition-export", idempotent = true)
152152
@OperationAccess("view-export")
153-
@AsyncSupported
153+
@AsyncSupported(redirectOnComplete = true)
154154
@Nullable
155155
public Parameters export(
156156
@Nullable @OperationParam(name = "view.name") final List<String> viewNames,

0 commit comments

Comments
 (0)