View Javadoc
1   /*
2    * The contents of this file are subject to the terms of the Common Development and
3    * Distribution License (the License). You may not use this file except in compliance with the
4    * License.
5    *
6    * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
7    * specific language governing permission and limitations under the License.
8    *
9    * When distributing Covered Software, include this CDDL Header Notice in each file and include
10   * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
11   * Header, with the fields enclosed by brackets [] replaced by your own identifying
12   * information: "Portions copyright [year] [name of copyright owner]".
13   *
14   * Copyright 2012-2017 ForgeRock AS.
15   * Portions Copyright 2026 Wren Security
16   */
17  package org.forgerock.json.resource.http;
18  
19  import static org.forgerock.http.util.Paths.addLeadingSlash;
20  import static org.forgerock.json.resource.QueryResponse.FIELD_ERROR;
21  import static org.forgerock.json.resource.QueryResponse.FIELD_PAGED_RESULTS_COOKIE;
22  import static org.forgerock.json.resource.QueryResponse.FIELD_REMAINING_PAGED_RESULTS;
23  import static org.forgerock.json.resource.QueryResponse.FIELD_RESULT;
24  import static org.forgerock.json.resource.QueryResponse.FIELD_RESULT_COUNT;
25  import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS;
26  import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS_POLICY;
27  import static org.forgerock.json.resource.Requests.newUpdateRequest;
28  import static org.forgerock.json.resource.ResourceException.newResourceException;
29  import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_ID;
30  import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_REVISION;
31  import static org.forgerock.json.resource.http.HttpUtils.HEADER_ETAG;
32  import static org.forgerock.json.resource.http.HttpUtils.HEADER_LOCATION;
33  import static org.forgerock.json.resource.http.HttpUtils.JSON_MAPPER;
34  import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_APPLICATION_JSON;
35  import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_TEXT_PLAIN;
36  import static org.forgerock.json.resource.http.HttpUtils.PROTOCOL_VERSION_2;
37  import static org.forgerock.json.resource.http.HttpUtils.adapt;
38  import static org.forgerock.json.resource.http.HttpUtils.fail;
39  import static org.forgerock.json.resource.http.HttpUtils.getIfNoneMatch;
40  import static org.forgerock.json.resource.http.HttpUtils.getJsonGenerator;
41  import static org.forgerock.json.resource.http.HttpUtils.getRequestedProtocolVersion;
42  import static org.forgerock.json.resource.http.HttpUtils.writeCacheControlHeader;
43  import static org.forgerock.json.resource.http.HttpUtils.writeContentTypeHeader;
44  import static org.forgerock.util.Utils.closeSilently;
45  import static org.forgerock.util.promise.Promises.newResultPromise;
46  
47  import com.fasterxml.jackson.core.JsonGenerator;
48  import com.fasterxml.jackson.databind.ObjectWriter;
49  import jakarta.mail.internet.ContentType;
50  import jakarta.mail.internet.ParseException;
51  import java.io.IOException;
52  import java.util.List;
53  import java.util.Map;
54  import java.util.concurrent.atomic.AtomicBoolean;
55  import java.util.concurrent.atomic.AtomicInteger;
56  import org.forgerock.http.header.ContentApiVersionHeader;
57  import org.forgerock.http.header.ContentTypeHeader;
58  import org.forgerock.http.header.MalformedHeaderException;
59  import org.forgerock.http.protocol.Response;
60  import org.forgerock.http.protocol.Status;
61  import org.forgerock.http.routing.UriRouterContext;
62  import org.forgerock.http.routing.Version;
63  import org.forgerock.http.util.Json;
64  import org.forgerock.json.JsonValue;
65  import org.forgerock.json.resource.ActionRequest;
66  import org.forgerock.json.resource.ActionResponse;
67  import org.forgerock.json.resource.AdviceContext;
68  import org.forgerock.json.resource.Connection;
69  import org.forgerock.json.resource.CreateNotSupportedException;
70  import org.forgerock.json.resource.CreateRequest;
71  import org.forgerock.json.resource.DeleteRequest;
72  import org.forgerock.json.resource.PatchRequest;
73  import org.forgerock.json.resource.PreconditionFailedException;
74  import org.forgerock.json.resource.QueryRequest;
75  import org.forgerock.json.resource.QueryResourceHandler;
76  import org.forgerock.json.resource.QueryResponse;
77  import org.forgerock.json.resource.ReadRequest;
78  import org.forgerock.json.resource.Request;
79  import org.forgerock.json.resource.RequestVisitor;
80  import org.forgerock.json.resource.ResourceException;
81  import org.forgerock.json.resource.ResourceResponse;
82  import org.forgerock.json.resource.UpdateRequest;
83  import org.forgerock.services.context.Context;
84  import org.forgerock.util.AsyncFunction;
85  import org.forgerock.util.encode.Base64url;
86  import org.forgerock.util.promise.ExceptionHandler;
87  import org.forgerock.util.promise.NeverThrowsException;
88  import org.forgerock.util.promise.Promise;
89  import org.forgerock.util.promise.ResultHandler;
90  
91  /**
92   * Common request processing.
93   */
94  final class RequestRunner implements RequestVisitor<Promise<Response, NeverThrowsException>, Void> {
95  
96      // Connection set on handleResult(Connection).
97      private Connection connection = null;
98      private final Context context;
99      private final org.forgerock.http.protocol.Request httpRequest;
100     private final Response httpResponse;
101     private final Version protocolVersion;
102     private final Request request;
103     private final JsonGenerator jsonGenerator;
104 
105     RequestRunner(Context context, Request request, org.forgerock.http.protocol.Request httpRequest,
106             Response httpResponse) throws Exception {
107         this.context = context;
108         this.request = request;
109         this.httpRequest = httpRequest;
110         this.httpResponse = httpResponse;
111         // cache the request's protocol version to avoid repeated BadRequestExceptions at call-sites
112         this.protocolVersion = getRequestedProtocolVersion(httpRequest);
113         this.jsonGenerator = getJsonGenerator(httpRequest, httpResponse);
114     }
115 
116     /**
117      * Determine if upsert is supported for this request.
118      *
119      * @param request the CreateRequest that failed
120      * @return whether we can instead try an update for the failed create
121      */
122     private boolean isUpsertSupported(final CreateRequest request) {
123         // protocol version 2 supports upsert -- update on create-failure
124         return (protocolVersion.getMajor() >= 2
125                 && getIfNoneMatch(httpRequest) == null
126                 && request.getNewResourceId() != null);
127     }
128 
129     public final Promise<Response, NeverThrowsException> handleError(final ResourceException error) {
130         onError(error);
131         writeApiVersionHeaders(error);
132         writeAdvice();
133         return fail(httpRequest, httpResponse, error);
134     }
135 
136     public final Promise<Response, NeverThrowsException> handleResult(final Connection result) {
137         connection = result;
138 
139         // Dispatch request using visitor.
140         return request.accept(this, null);
141     }
142 
143     /**
144      * {@inheritDoc}
145      */
146     @Override
147     public final Promise<Response, NeverThrowsException> visitActionRequest(final Void p, final ActionRequest request) {
148         return connection.actionAsync(context, request)
149                 .thenAsync(new AsyncFunction<ActionResponse, Response, NeverThrowsException>() {
150                     @Override
151                     public Promise<Response, NeverThrowsException> apply(ActionResponse result) {
152                         try {
153                             writeApiVersionHeaders(result);
154                             writeCacheControlHeader(httpResponse);
155                             writeAdvice();
156                             if (result != null) {
157                                 writeContentTypeHeader(httpResponse);
158                                 Json.makeLocalizingObjectWriter(JSON_MAPPER, httpRequest)
159                                         .writeValue(jsonGenerator, result.getJsonContent().getObject());
160                             } else {
161                                 // No content.
162                                 httpResponse.setStatus(Status.NO_CONTENT);
163                             }
164                             onSuccess();
165                         } catch (final Exception e) {
166                             onError(e);
167                         }
168 
169                         return newResultPromise(httpResponse);
170                     }
171                 }, new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
172                     @Override
173                     public Promise<Response, NeverThrowsException> apply(ResourceException e) {
174                         return handleError(e);
175                     }
176                 });
177     }
178 
179     /**
180      * {@inheritDoc}
181      */
182     @Override
183     public final Promise<Response, NeverThrowsException> visitCreateRequest(final Void p, final CreateRequest request) {
184         return connection.createAsync(context, request)
185                 .thenAsync(new AsyncFunction<ResourceResponse, Response, NeverThrowsException>() {
186                     @Override
187                     public Promise<Response, NeverThrowsException> apply(ResourceResponse result) {
188                         try {
189                             writeApiVersionHeaders(result);
190                             writeAdvice();
191                             if (result.getId() != null) {
192                                 httpResponse.getHeaders().put(HEADER_LOCATION, getResourceURL(result, request));
193                             }
194                             httpResponse.setStatus(Status.CREATED);
195                             writeContentTypeHeader(httpResponse);
196                             writeCacheControlHeader(httpResponse);
197                             writeResource(result);
198                             onSuccess();
199                         } catch (final Exception e) {
200                             onError(e);
201                         }
202                         return newResultPromise(httpResponse);
203                     }
204                 }, new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
205                     @Override
206                     public Promise<Response, NeverThrowsException> apply(ResourceException resourceException) {
207                         try {
208                             // treat as update to existing resource (if supported)
209                             // if create failed because object already exists
210                             if ((resourceException instanceof PreconditionFailedException
211                                     || resourceException instanceof CreateNotSupportedException)
212                                     && isUpsertSupported(request)) {
213                                 return visitUpdateRequest(p,
214                                         newUpdateRequest(
215                                                 request.getResourcePathObject().child(request.getNewResourceId()),
216                                                 request.getContent()));
217                             } else {
218                                 return handleError(resourceException);
219                             }
220                         } catch (Exception e) {
221                             onError(e);
222                         }
223                         return newResultPromise(httpResponse);
224                     }
225                 });
226     }
227 
228     /**
229      * {@inheritDoc}
230      */
231     @Override
232     public final Promise<Response, NeverThrowsException> visitDeleteRequest(final Void p, final DeleteRequest request) {
233         return connection.deleteAsync(context, request)
234                 .thenAsync(newResourceSuccessHandler(),
235                         new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
236                             @Override
237                             public Promise<Response, NeverThrowsException> apply(ResourceException e) {
238                                 return handleError(e);
239                             }
240                         });
241     }
242 
243     /**
244      * {@inheritDoc}
245      */
246     @Override
247     public final Promise<Response, NeverThrowsException> visitPatchRequest(final Void p, final PatchRequest request) {
248         return connection.patchAsync(context, request)
249                 .thenAsync(newResourceSuccessHandler(),
250                         new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
251                             @Override
252                             public Promise<Response, NeverThrowsException> apply(ResourceException e) {
253                                 return handleError(e);
254                             }
255                         });
256     }
257 
258     /**
259      * {@inheritDoc}
260      */
261     @Override
262     public final Promise<Response, NeverThrowsException> visitQueryRequest(final Void p, final QueryRequest request) {
263         final AtomicBoolean isFirstResult = new AtomicBoolean(true);
264         final AtomicInteger resultCount = new AtomicInteger(0);
265         return connection.queryAsync(context, request, new QueryResourceHandler() {
266             @Override
267             public boolean handleResource(final ResourceResponse resource) {
268                 try {
269                     writeHeader(resource, isFirstResult);
270                     writeResourceJsonContent(resource);
271                     resultCount.incrementAndGet();
272                     return true;
273                 } catch (final Exception e) {
274                     handleError(adapt(e));
275                     return false;
276                 }
277             }
278         }).thenOnResult(new ResultHandler<QueryResponse>() {
279             @Override
280             public void handleResult(QueryResponse result) {
281                 try {
282                     writeHeader(result, isFirstResult);
283                     jsonGenerator.writeEndArray();
284                     jsonGenerator.writeNumberField(FIELD_RESULT_COUNT, resultCount.get());
285                     jsonGenerator.writeStringField(FIELD_PAGED_RESULTS_COOKIE, result.getPagedResultsCookie());
286                     jsonGenerator.writeStringField(FIELD_TOTAL_PAGED_RESULTS_POLICY,
287                             result.getTotalPagedResultsPolicy().toString());
288                     jsonGenerator.writeNumberField(FIELD_TOTAL_PAGED_RESULTS, result.getTotalPagedResults());
289                     // Remaining is only present for backwards compatibility with CREST2 via Accept-API-Version
290                     jsonGenerator.writeNumberField(FIELD_REMAINING_PAGED_RESULTS, result.getRemainingPagedResults());
291                     jsonGenerator.writeEndObject();
292                     onSuccess();
293                 } catch (final Exception e) {
294                     onError(e);
295                 }
296             }
297         }).thenOnException(new ExceptionHandler<ResourceException>() {
298             @Override
299             public void handleException(ResourceException error) {
300                 if (isFirstResult.get()) {
301                     onError(error);
302                 } else {
303                     // Partial results - it's too late to set the status.
304                     try {
305                         jsonGenerator.writeEndArray();
306                         jsonGenerator.writeNumberField(FIELD_RESULT_COUNT, resultCount.get());
307                         jsonGenerator.writeObjectField(FIELD_ERROR, error.toJsonValue().getObject());
308                         jsonGenerator.writeEndObject();
309                         onSuccess();
310                     } catch (final Exception e) {
311                         onError(e);
312                     }
313                 }
314             }
315         }).thenAsync(new AsyncFunction<QueryResponse, Response, NeverThrowsException>() {
316             @Override
317             public Promise<Response, NeverThrowsException> apply(QueryResponse queryResponse) {
318                 return newResultPromise(httpResponse);
319             }
320         }, new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
321             @Override
322             public Promise<Response, NeverThrowsException> apply(ResourceException e) {
323                 return handleError(e);
324             }
325         });
326     }
327 
328     private void writeHeader(org.forgerock.json.resource.Response response, AtomicBoolean isFirstResult)
329             throws IOException {
330         if (isFirstResult.compareAndSet(true, false)) {
331             writeApiVersionHeaders(response);
332             writeAdvice();
333             writeContentTypeHeader(httpResponse);
334             writeCacheControlHeader(httpResponse);
335             jsonGenerator.writeStartObject();
336             jsonGenerator.writeArrayFieldStart(FIELD_RESULT);
337         }
338     }
339 
340     /**
341      * {@inheritDoc}
342      */
343     @Override
344     public final Promise<Response, NeverThrowsException> visitReadRequest(final Void p, final ReadRequest request) {
345         return connection.readAsync(context, request)
346                 .thenAsync(newResourceSuccessHandler(),
347                         new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
348                             @Override
349                             public Promise<Response, NeverThrowsException> apply(ResourceException e) {
350                                 return handleError(e);
351                             }
352                         });
353     }
354 
355     /**
356      * {@inheritDoc}
357      */
358     @Override
359     public final Promise<Response, NeverThrowsException> visitUpdateRequest(final Void p, final UpdateRequest request) {
360         return connection.updateAsync(context, request)
361                 .thenAsync(newResourceSuccessHandler(),
362                         new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
363                             @Override
364                             public Promise<Response, NeverThrowsException> apply(ResourceException e) {
365                                 return handleError(e);
366                             }
367                         });
368     }
369 
370     private void onSuccess() {
371         closeSilently(connection, jsonGenerator);
372     }
373 
374     private void onError(final Exception e) {
375         // Don't close the JSON generator because the request will become
376         // "completed" which then prevents us from sending an error.
377         closeSilently(connection);
378     }
379 
380     private String getResourceURL(final ResourceResponse resource, final CreateRequest request) {
381         // Strip out everything except the scheme and host.
382         StringBuilder builder = new StringBuilder()
383                 .append(httpRequest.getUri().getScheme())
384                 .append("://")
385                 .append(httpRequest.getUri().getRawAuthority());
386 
387         // Add the routed path ...
388         String baseUri = context.asContext(UriRouterContext.class).getBaseUri();
389         if (!baseUri.isEmpty()) {
390             builder.append(addLeadingSlash(baseUri));
391         }
392 
393         // ... the container path ...
394         String resourcePath = request.getResourcePath();
395         if (!resourcePath.isEmpty()) {
396             builder.append(addLeadingSlash(resourcePath));
397         }
398 
399         // ... and the resource ID
400         builder.append('/');
401         builder.append(resource.getId());
402 
403         return builder.toString();
404     }
405 
406     private AsyncFunction<ResourceResponse, Response, NeverThrowsException> newResourceSuccessHandler() {
407         return new AsyncFunction<ResourceResponse, Response, NeverThrowsException>() {
408             @Override
409             public Promise<Response, NeverThrowsException> apply(ResourceResponse result) {
410                 try {
411                     writeApiVersionHeaders(result);
412                     writeAdvice();
413                     // Don't return the resource if this is a read request and the
414                     // If-None-Match header was specified.
415                     if (request instanceof ReadRequest) {
416                         final String rev = getIfNoneMatch(httpRequest);
417                         if (rev != null && rev.equals(result.getRevision())) {
418                             // No change so 304.
419                             Map<String, Object> responseBody = newResourceException(304)
420                                     .setReason("Not Modified").toJsonValue().asMap();
421                             return newResultPromise(new Response(Status.valueOf(304))
422                                     .setEntity(responseBody));
423                         }
424                     }
425                     writeContentTypeHeader(httpResponse);
426                     writeCacheControlHeader(httpResponse);
427                     writeResource(result);
428                     onSuccess();
429                 } catch (final Exception e) {
430                     onError(e);
431                 }
432                 return newResultPromise(httpResponse);
433             }
434         };
435     }
436 
437     private void writeTextValue(final JsonValue json) throws IOException {
438         if (json.isMap() && !json.asMap().isEmpty()) {
439             writeToResponse(json.asMap().entrySet().iterator().next().getValue().toString().getBytes());
440         } else if (json.isList() && !json.asList().isEmpty()) {
441             writeToResponse(json.asList(String.class).iterator().next().getBytes());
442         } else if (json.isString()) {
443             writeToResponse(json.asString().getBytes());
444         } else if (json.isBoolean()) {
445             writeToResponse(json.asBoolean().toString().getBytes());
446         } else if (json.isNumber()) {
447             writeToResponse(json.asNumber().toString().getBytes());
448         } else {
449             throw new IOException("Content is unknown type or is empty");
450         }
451     }
452 
453     private void writeBinaryValue(final JsonValue json) throws IOException {
454         if (json.isMap() && !json.asMap().isEmpty()) {
455             writeToResponse(Base64url.decode(json.asMap().entrySet().iterator().next().getValue().toString()));
456         } else if (json.isList() && !json.asList().isEmpty()) {
457             writeToResponse(Base64url.decode(json.asList(String.class).iterator().next()));
458         } else if (json.isString()) {
459             writeToResponse(Base64url.decode(json.asString()));
460         } else {
461             throw new IOException("Content is not an accepted type or is empty");
462         }
463     }
464 
465     private void writeToResponse(byte[] data) throws IOException {
466         if (data == null || data.length == 0) {
467             throw new IOException("Content is empty or corrupt");
468         }
469         httpResponse.setEntity(data);
470     }
471 
472     private void writeResource(final ResourceResponse resource)
473             throws IOException, ParseException, MalformedHeaderException {
474         if (resource.getRevision() != null) {
475             final StringBuilder builder = new StringBuilder();
476             builder.append('"');
477             builder.append(resource.getRevision());
478             builder.append('"');
479             httpResponse.getHeaders().put(HEADER_ETAG, builder.toString());
480         }
481 
482         ContentType contentType = new ContentType(httpResponse.getHeaders().getFirst(ContentTypeHeader.class));
483 
484         if (contentType.match(MIME_TYPE_APPLICATION_JSON)) {
485             writeResourceJsonContent(resource);
486         } else if (contentType.match(MIME_TYPE_TEXT_PLAIN)) {
487             writeTextValue(resource.getContent());
488         } else {
489             writeBinaryValue(resource.getContent());
490         }
491     }
492 
493     /**
494      * Writes a JSON resource taking care to ensure that the _id and _rev fields are always serialized regardless of
495      * the field filtering. It is essential that these fields are included so that clients can reconstruct
496      * ResourceResponse object's "id" and "revision" properties. In addition, it is reasonable to assume that query
497      * results should always include at least the _id field otherwise it will be difficult to perform any useful
498      * client side result processing.
499      */
500     private void writeResourceJsonContent(final ResourceResponse resource)
501             throws IOException, MalformedHeaderException {
502         ObjectWriter objectWriter = Json.makeLocalizingObjectWriter(JSON_MAPPER, httpRequest);
503         if (getRequestedProtocolVersion(httpRequest).getMajor() >= PROTOCOL_VERSION_2.getMajor()) {
504             jsonGenerator.writeStartObject();
505             final JsonValue content = resource.getContent();
506 
507             if (resource.getId() != null) {
508                 jsonGenerator.writeObjectField(FIELD_CONTENT_ID, resource.getId());
509             } else {
510                 // Defensively extract an object instead of a string in case application code has stored a UUID
511                 // object, or some other non-JSON primitive. Also assume that a null ID means no ID.
512                 final Object id = content.get(FIELD_CONTENT_ID).getObject();
513                 if (id != null) {
514                     jsonGenerator.writeObjectField(FIELD_CONTENT_ID, id.toString());
515                 }
516             }
517 
518             if (resource.getRevision() != null) {
519                 jsonGenerator.writeObjectField(FIELD_CONTENT_REVISION, resource.getRevision());
520             } else {
521                 // Defensively extract an object instead of a string in case application code has stored a Number
522                 // object, or some other non-JSON primitive. Also assume that a null revision means no revision.
523                 final Object rev = content.get(FIELD_CONTENT_REVISION).getObject();
524                 if (rev != null) {
525                     jsonGenerator.writeObjectField(FIELD_CONTENT_REVISION, rev.toString());
526                 }
527             }
528 
529             for (Map.Entry<String, Object> property : content.asMap().entrySet()) {
530                 final String key = property.getKey();
531                 if (!FIELD_CONTENT_ID.equals(key) && !FIELD_CONTENT_REVISION.equals(key)) {
532                     jsonGenerator.writeFieldName(key);
533                     objectWriter.writeValue(jsonGenerator, property.getValue());
534                 }
535             }
536             jsonGenerator.writeEndObject();
537         } else {
538             objectWriter.writeValue(jsonGenerator, resource.getContent().getObject());
539         }
540     }
541 
542     private void writeApiVersionHeaders(org.forgerock.json.resource.Response response) {
543         if (response.getResourceApiVersion() != null) {
544             httpResponse.getHeaders().put(
545                     new ContentApiVersionHeader(protocolVersion, response.getResourceApiVersion()));
546         }
547     }
548 
549     private void writeAdvice() {
550         if (context.containsContext(AdviceContext.class)) {
551             AdviceContext adviceContext = context.asContext(AdviceContext.class);
552             for (Map.Entry<String, List<String>> entry : adviceContext.getAdvices().entrySet()) {
553                 httpResponse.getHeaders().put(entry.getKey(), entry.getValue());
554             }
555         }
556     }
557 }