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 2015-2016 ForgeRock AS.
15   */
16  
17  package org.forgerock.json.resource.http;
18  
19  import static java.lang.String.format;
20  import static java.util.Arrays.asList;
21  import static org.forgerock.http.protocol.Status.CREATED;
22  import static org.forgerock.http.protocol.Status.NO_CONTENT;
23  import static org.forgerock.http.protocol.Status.OK;
24  import static org.forgerock.json.JsonValueFunctions.enumConstant;
25  import static org.forgerock.json.resource.QueryResponse.FIELD_ERROR;
26  import static org.forgerock.json.resource.QueryResponse.FIELD_PAGED_RESULTS_COOKIE;
27  import static org.forgerock.json.resource.QueryResponse.FIELD_RESULT;
28  import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS;
29  import static org.forgerock.json.resource.QueryResponse.FIELD_TOTAL_PAGED_RESULTS_POLICY;
30  import static org.forgerock.json.resource.QueryResponse.NO_COUNT;
31  import static org.forgerock.json.resource.ResourceException.FIELD_CODE;
32  import static org.forgerock.json.resource.ResourceException.FIELD_DETAIL;
33  import static org.forgerock.json.resource.ResourceException.FIELD_MESSAGE;
34  import static org.forgerock.json.resource.ResourceException.FIELD_REASON;
35  import static org.forgerock.json.resource.ResourceException.newResourceException;
36  import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_ID;
37  import static org.forgerock.json.resource.ResourceResponse.FIELD_CONTENT_REVISION;
38  import static org.forgerock.json.resource.Responses.newActionResponse;
39  import static org.forgerock.json.resource.Responses.newQueryResponse;
40  import static org.forgerock.json.resource.Responses.newResourceResponse;
41  import static org.forgerock.json.resource.http.HttpUtils.DEFAULT_PROTOCOL_VERSION;
42  import static org.forgerock.json.resource.http.HttpUtils.ETAG_ANY;
43  import static org.forgerock.json.resource.http.HttpUtils.FIELDS_DELIMITER;
44  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_MATCH;
45  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_NONE_MATCH;
46  import static org.forgerock.json.resource.http.HttpUtils.METHOD_DELETE;
47  import static org.forgerock.json.resource.http.HttpUtils.METHOD_GET;
48  import static org.forgerock.json.resource.http.HttpUtils.METHOD_PATCH;
49  import static org.forgerock.json.resource.http.HttpUtils.METHOD_POST;
50  import static org.forgerock.json.resource.http.HttpUtils.METHOD_PUT;
51  import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_APPLICATION_JSON;
52  import static org.forgerock.json.resource.http.HttpUtils.PARAM_ACTION;
53  import static org.forgerock.json.resource.http.HttpUtils.PARAM_FIELDS;
54  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_COOKIE;
55  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_OFFSET;
56  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGE_SIZE;
57  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_EXPRESSION;
58  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_FILTER;
59  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_ID;
60  import static org.forgerock.json.resource.http.HttpUtils.PARAM_SORT_KEYS;
61  import static org.forgerock.json.resource.http.HttpUtils.PARAM_TOTAL_PAGED_RESULTS_POLICY;
62  import static org.forgerock.json.resource.http.HttpUtils.SORT_KEYS_DELIMITER;
63  import static org.forgerock.util.CloseSilentlyFunction.closeSilently;
64  import static org.forgerock.util.Reject.checkNotNull;
65  import static org.forgerock.util.Utils.joinAsString;
66  
67  import java.io.IOException;
68  import java.net.URI;
69  import java.net.URISyntaxException;
70  import java.util.LinkedList;
71  import java.util.List;
72  import java.util.Map;
73  
74  import org.forgerock.http.Handler;
75  import org.forgerock.http.MutableUri;
76  import org.forgerock.http.protocol.Responses;
77  import org.forgerock.http.header.AcceptApiVersionHeader;
78  import org.forgerock.http.header.ContentApiVersionHeader;
79  import org.forgerock.http.header.ContentTypeHeader;
80  import org.forgerock.http.protocol.Form;
81  import org.forgerock.http.protocol.Request;
82  import org.forgerock.http.protocol.Response;
83  import org.forgerock.http.protocol.Status;
84  import org.forgerock.http.routing.Version;
85  import org.forgerock.json.JsonValue;
86  import org.forgerock.json.resource.ActionRequest;
87  import org.forgerock.json.resource.ActionResponse;
88  import org.forgerock.json.resource.CountPolicy;
89  import org.forgerock.json.resource.CreateRequest;
90  import org.forgerock.json.resource.DeleteRequest;
91  import org.forgerock.json.resource.InternalServerErrorException;
92  import org.forgerock.json.resource.PatchOperation;
93  import org.forgerock.json.resource.PatchRequest;
94  import org.forgerock.json.resource.QueryRequest;
95  import org.forgerock.json.resource.QueryResourceHandler;
96  import org.forgerock.json.resource.QueryResponse;
97  import org.forgerock.json.resource.ReadRequest;
98  import org.forgerock.json.resource.RequestHandler;
99  import org.forgerock.json.resource.ResourceException;
100 import org.forgerock.json.resource.ResourceResponse;
101 import org.forgerock.json.resource.SortKey;
102 import org.forgerock.json.resource.UpdateRequest;
103 import org.forgerock.services.context.Context;
104 import org.forgerock.util.Function;
105 import org.forgerock.util.promise.Promise;
106 
107 /**
108  * This class is a bridge between CREST and CHF (the counter-part of {@link HttpAdapter}): it is used to transform
109  * CREST {@link org.forgerock.json.resource.Request} into CHF {@link Request} and CHF {@link Response} back in
110  * CREST {@link org.forgerock.json.resource.Response}.
111  *
112  * Example:
113  * <pre>
114  *     {@code
115  *     RequestHandler client = CrestHttp.newRequestHandler(new HttpClientHandler(),
116  *                                                         new URI("http://www.example.com/api/"));
117  *     }
118  * </pre>
119  *
120  * You can even wrap it into a {@link org.forgerock.json.resource.Connection} for the fluent API:
121  * <pre>
122  *     {@code
123  *     Connection connection = Resources.newInternalConnection(client);
124  *     ResourceResponse response = connection.create(context,
125  *                                                   newCreateRequest("/users",
126  *                                                                    "bjensen",
127  *                                                                    json(object(field("login", "bjensen")))));
128  *     }
129  * </pre>
130  *
131  * <p><strong>Implementation note:</strong> We do not want to resurrect
132  * {@link org.forgerock.json.resource.AdviceContext AdviceContext} from returned HTTP headers.
133  * Contexts should not be used for communicating protocol content.
134  * Anything that is to be communicated between peers should be exposed as part of the Request/Response interfaces,
135  * such as preferred languages, resource API version, etc. If we have a specific use case for Advices then they should
136  * be exposed as properties of the Response.
137  */
138 final class CrestAdapter implements RequestHandler {
139 
140     private static final Status NOT_MODIFIED = Status.valueOf(304, "Not Modified");
141 
142     private final Handler handler;
143     private final URI baseUri;
144 
145     /**
146      * Constructs a new {@link CrestAdapter} wrapping the given HTTP {@code handler}.
147      *
148      * @param handler
149      *         HTTP handler that will handle translated requests
150      * @param uri
151      *         base URI (need to end with a {@code /}) for HTTP requests
152      */
153     public CrestAdapter(Handler handler, URI uri) {
154         this.handler = checkNotNull(handler);
155         this.baseUri = checkNotNull(uri);
156     }
157 
158     @Override
159     public Promise<ActionResponse, ResourceException> handleAction(Context context, ActionRequest request) {
160 
161         Request httpRequest = new Request();
162         prepareHttpRequest(request, httpRequest);
163         httpRequest.setMethod(METHOD_POST);
164         Form form = new Form();
165         form.putSingle(PARAM_ACTION, request.getAction());
166         form.appendRequestQuery(httpRequest);
167         if (request.getContent() != null) {
168             httpRequest.getEntity().setJson(request.getContent().getObject());
169         }
170 
171         // Expect OK or NO_CONTENT
172         return handler.handle(context, httpRequest)
173                       .then(closeSilently(new Function<Response, ActionResponse, ResourceException>() {
174                           @Override
175                           public ActionResponse apply(Response response) throws ResourceException {
176                               // Transform HTTP response to CREST ActionResponse
177 
178                               // CREST always output message with either application/json, text/plain,
179                               // everything else is considered as a binary content
180 
181                               // We'll never output a request with 'mimeType'
182                               // so the output content is always application/json
183                               JsonValue content = loadJsonValueContent(response);
184 
185                               if (OK.equals(response.getStatus()) || NO_CONTENT.equals(response.getStatus())) {
186                                   return setResourceVersion(response, newActionResponse(content));
187                               } else {
188                                   throw createResourceException(response, content);
189                               }
190                           }
191                       }), Responses.<ActionResponse, ResourceException>noopExceptionFunction());
192     }
193 
194     @Override
195     public Promise<ResourceResponse, ResourceException> handleCreate(Context context, CreateRequest request) {
196         final Request httpRequest = new Request();
197         prepareHttpRequest(request, httpRequest);
198 
199         String resourceId = request.getNewResourceId();
200         if (resourceId == null) {
201             // container generated ID => POST
202             httpRequest.setMethod(METHOD_POST);
203         } else {
204             // caller provided ID => PUT + If-None-Match: *
205             httpRequest.setMethod(METHOD_PUT);
206             MutableUri uri = httpRequest.getUri();
207             try {
208                 // path and new resource id are not URL encoded (uri will take of that automatically)
209                 uri.setPath(uri.getPath() + "/" + request.getNewResourceId());
210             } catch (URISyntaxException e) {
211                 return new InternalServerErrorException("Cannot rebuild resource path", e).asPromise();
212             }
213             setIfNoneMatchToAny(httpRequest);
214         }
215 
216         if (request.getContent() != null) {
217             httpRequest.getEntity().setJson(request.getContent().getObject());
218         }
219 
220         // Expect CREATED
221         return handler.handle(context, httpRequest)
222                       .then(closeSilently(new Function<Response, ResourceResponse, ResourceException>() {
223                           @Override
224                           public ResourceResponse apply(Response response) throws ResourceException {
225                               // Transform HTTP response to CREST ResourceResponse
226 
227                               // CREST always output message with either application/json, text/plain,
228                               // everything else is considered as a binary content
229 
230                               // We'll never output a request with 'mimeType'
231                               // so the output content is always application/json
232                               JsonValue content = loadJsonValueContent(response);
233 
234                               if (CREATED.equals(response.getStatus())) {
235                                   return setResourceVersion(response, createResourceResponse(content));
236                               } else {
237                                   throw createResourceException(response, content);
238                               }
239                           }
240                       }), Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
241     }
242 
243     @Override
244     public Promise<ResourceResponse, ResourceException> handleDelete(Context context, DeleteRequest request) {
245 
246         Request httpRequest = new Request();
247         httpRequest.setMethod(METHOD_DELETE);
248         prepareHttpRequest(request, httpRequest);
249         setIfMatch(httpRequest, request.getRevision());
250 
251         // Expect OK
252         return handler.handle(context, httpRequest)
253                       .then(buildCrestResponse(asList(Status.OK)),
254                             Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
255     }
256 
257     @Override
258     public Promise<ResourceResponse, ResourceException> handlePatch(Context context, PatchRequest request) {
259         Request httpRequest = new Request();
260         httpRequest.setMethod(METHOD_PATCH);
261         prepareHttpRequest(request, httpRequest);
262         setIfMatch(httpRequest, request.getRevision());
263 
264         if (!request.getPatchOperations().isEmpty()) {
265             JsonValue content = new JsonValue(new LinkedList<>());
266             for (PatchOperation operation : request.getPatchOperations()) {
267                 content.add(operation.toJsonValue().getObject());
268             }
269             httpRequest.getEntity().setJson(content.getObject());
270         }
271 
272         // Expect OK
273         return handler.handle(context, httpRequest)
274                       .then(buildCrestResponse(asList(Status.OK)),
275                             Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
276     }
277 
278     @Override
279     public Promise<QueryResponse, ResourceException> handleQuery(Context context,
280                                                                  QueryRequest request,
281                                                                  final QueryResourceHandler queryHandler) {
282         Request httpRequest = new Request();
283         prepareHttpRequest(request, httpRequest);
284         httpRequest.setMethod(METHOD_GET);
285         Form form = new Form();
286         putIfNotNull(form, PARAM_QUERY_ID, request.getQueryId());
287         putIfNotNull(form, PARAM_QUERY_EXPRESSION, request.getQueryExpression());
288         putIfNotNull(form, PARAM_QUERY_FILTER, request.getQueryFilter());
289         putIfNotNull(form, PARAM_TOTAL_PAGED_RESULTS_POLICY, request.getTotalPagedResultsPolicy());
290         putIfNotNull(form, PARAM_PAGED_RESULTS_COOKIE, request.getPagedResultsCookie());
291         List<SortKey> sortKeys = request.getSortKeys();
292         if (sortKeys != null && !sortKeys.isEmpty()) {
293             form.putSingle(PARAM_SORT_KEYS, joinAsString(SORT_KEYS_DELIMITER));
294         }
295         if (request.getPageSize() > 0) {
296             form.putSingle(PARAM_PAGE_SIZE, String.valueOf(request.getPageSize()));
297         }
298         if (request.getPagedResultsOffset() >= 1) {
299             form.putSingle(PARAM_PAGED_RESULTS_OFFSET, String.valueOf(request.getPagedResultsOffset()));
300         }
301         if (!form.isEmpty()) {
302             form.appendRequestQuery(httpRequest);
303         }
304 
305         // Expect OK
306         return handler.handle(context, httpRequest)
307                       .then(closeSilently(new Function<Response, QueryResponse, ResourceException>() {
308                           @Override
309                           public QueryResponse apply(Response response) throws ResourceException {
310                               // Transform HTTP response to CREST ActionResponse
311 
312                               // CREST always output message with either application/json, text/plain,
313                               // everything else is considered as a binary content
314 
315                               // We'll never output a request with 'mimeType'
316                               // so the output content is always application/json
317                               JsonValue content = loadJsonValueContent(response);
318 
319                               if (OK.equals(response.getStatus()) && !content.isDefined(FIELD_ERROR)) {
320 
321                                   String pagedResultsCookie = content.get(FIELD_PAGED_RESULTS_COOKIE).asString();
322                                   CountPolicy countPolicy = content.get(FIELD_TOTAL_PAGED_RESULTS_POLICY)
323                                                                    .as(enumConstant(CountPolicy.class));
324                                   Integer totalPagedResults = content.get(FIELD_TOTAL_PAGED_RESULTS)
325                                                                      .defaultTo(NO_COUNT)
326                                                                      .asInteger();
327                                   QueryResponse queryResponse = newQueryResponse(pagedResultsCookie,
328                                                                                  countPolicy,
329                                                                                  totalPagedResults);
330                                   for (JsonValue value : content.get(FIELD_RESULT)) {
331                                       queryHandler.handleResource(createResourceResponse(value));
332                                   }
333 
334                                   return setResourceVersion(response, queryResponse);
335                               } else {
336                                   throw createResourceException(response, content);
337                               }
338                           }
339                       }), Responses.<QueryResponse, ResourceException>noopExceptionFunction());
340     }
341 
342     @Override
343     public Promise<ResourceResponse, ResourceException> handleRead(Context context, ReadRequest request) {
344 
345         Request httpRequest = new Request();
346         httpRequest.setMethod(METHOD_GET);
347         prepareHttpRequest(request, httpRequest);
348 
349         // Expect OK or NOT_MODIFIED(304)
350         return handler.handle(context, httpRequest)
351                       .then(buildCrestResponse(asList(Status.OK, NOT_MODIFIED)),
352                             Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
353     }
354 
355     @Override
356     public Promise<ResourceResponse, ResourceException> handleUpdate(Context context, UpdateRequest request) {
357 
358         Request httpRequest = new Request();
359         httpRequest.setMethod(METHOD_PUT);
360         prepareHttpRequest(request, httpRequest);
361         setIfMatch(httpRequest, request.getRevision());
362         if (request.getContent() != null) {
363             httpRequest.getEntity().setJson(request.getContent().getObject());
364         }
365 
366         // Only expect OK
367         return handler.handle(context, httpRequest)
368                       .then(buildCrestResponse(asList(Status.OK)),
369                             Responses.<ResourceResponse, ResourceException>noopExceptionFunction());
370     }
371 
372     private static Function<Response, ResourceResponse, ResourceException> buildCrestResponse(
373             final List<Status> accepted) {
374         return closeSilently(new Function<Response, ResourceResponse, ResourceException>() {
375             @Override
376             public ResourceResponse apply(Response response) throws ResourceException {
377                 // Transform HTTP response to CREST ResourceResponse
378 
379                 // CREST always output message with either application/json, text/plain,
380                 // everything else is considered as a binary content
381 
382                 // We'll never output a request with 'mimeType'
383                 // so the output content is always application/json
384                 JsonValue content = loadJsonValueContent(response);
385 
386                 if (accepted.contains(response.getStatus())) {
387                     return setResourceVersion(response, createResourceResponse(content));
388                 } else {
389                     throw createResourceException(response, content);
390                 }
391             }
392         });
393     }
394 
395     private static void putIfNotNull(Form form, String name, Object value) {
396         if (value != null) {
397             form.putSingle(name, value.toString());
398         }
399     }
400 
401     private static JsonValue loadJsonValueContent(final Response response) throws ResourceException {
402         if (MIME_TYPE_APPLICATION_JSON.equals(ContentTypeHeader.valueOf(response).getType())) {
403             try {
404                 return new JsonValue(response.getEntity().getJson());
405             } catch (IOException e) {
406                 throw new InternalServerErrorException("Cannot parse HTTP response content as JSON", e);
407             }
408         }
409         throw new InternalServerErrorException("Response is not application/json");
410     }
411 
412     private static ResourceResponse createResourceResponse(final JsonValue content) {
413         return newResourceResponse(content.get(FIELD_CONTENT_ID).asString(),
414                                    content.get(FIELD_CONTENT_REVISION).asString(),
415                                    content);
416     }
417 
418     private static <T extends org.forgerock.json.resource.Response> T setResourceVersion(final Response httpResponse,
419                                                                                          final T result) {
420         if (httpResponse.getHeaders().containsKey(ContentApiVersionHeader.NAME)) {
421             Version resourceVersion = ContentApiVersionHeader.valueOf(httpResponse).getResourceVersion();
422             result.setResourceApiVersion(resourceVersion);
423         }
424         return result;
425     }
426 
427     private static void setRequestedResourceVersion(Request request, Version resourceVersion) {
428         // Force protocol version to 2.0 (current) at least
429         request.getHeaders().put(new AcceptApiVersionHeader(DEFAULT_PROTOCOL_VERSION,
430                                                                   resourceVersion));
431     }
432 
433     private static ResourceException createResourceException(final Response response, final JsonValue content) {
434         ResourceException exception = newResourceException(content.get(FIELD_CODE)
435                                                                   .defaultTo(response.getStatus().getCode())
436                                                                   .asInteger(),
437                                                            content.get(FIELD_MESSAGE).asString());
438 
439         if (content.isDefined(FIELD_DETAIL)) {
440             exception.setDetail(content.get(FIELD_DETAIL));
441         }
442         if (content.isDefined(FIELD_REASON)) {
443             exception.setReason(content.get(FIELD_REASON).asString());
444         }
445         // TODO Add other fields (cause) ?
446         return setResourceVersion(response, exception);
447     }
448 
449     private static void setIfMatch(final Request request, final String revision) {
450         String value = "*";
451         if (revision != null) {
452             value = format("\"%s\"", revision);
453         }
454         request.getHeaders().put(HEADER_IF_MATCH, value);
455     }
456 
457     private static void setIfNoneMatchToAny(final Request request) {
458         request.getHeaders().put(HEADER_IF_NONE_MATCH, ETAG_ANY);
459     }
460 
461     private void prepareHttpRequest(final org.forgerock.json.resource.Request request, final Request httpRequest) {
462         setRequestedResourceVersion(httpRequest, request.getResourceVersion());
463 
464         httpRequest.setUri(baseUri.resolve(request.getResourcePath()));
465 
466         final Form form = new Form();
467         if (!request.getFields().isEmpty()) {
468             form.putSingle(PARAM_FIELDS, joinAsString(FIELDS_DELIMITER, request.getFields().toArray()));
469         }
470         for (Map.Entry<String, String> entry : request.getAdditionalParameters().entrySet()) {
471             form.putSingle(entry.getKey(), entry.getValue());
472         }
473         if (!form.isEmpty()) {
474             form.toRequestQuery(httpRequest);
475         }
476     }
477 
478 }