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