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-2016 ForgeRock AS.
15   * Portions Copyright 2026 Wren Security
16   */
17  package org.forgerock.json.resource.http;
18  
19  import static java.util.concurrent.TimeUnit.MINUTES;
20  import static org.forgerock.api.commons.CommonsApi.COMMONS_API_DESCRIPTION;
21  import static org.forgerock.http.util.Paths.addLeadingSlash;
22  import static org.forgerock.http.util.Paths.removeTrailingSlash;
23  import static org.forgerock.json.resource.Applications.simpleCrestApplication;
24  import static org.forgerock.json.resource.Requests.newApiRequest;
25  import static org.forgerock.json.resource.ResourcePath.resourcePath;
26  import static org.forgerock.json.resource.http.HttpUtils.CONTENT_TYPE_REGEX;
27  import static org.forgerock.json.resource.http.HttpUtils.ETAG_ANY;
28  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_MATCH;
29  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_MODIFIED_SINCE;
30  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_NONE_MATCH;
31  import static org.forgerock.json.resource.http.HttpUtils.HEADER_IF_UNMODIFIED_SINCE;
32  import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_APPLICATION_JSON;
33  import static org.forgerock.json.resource.http.HttpUtils.MIME_TYPE_MULTIPART_FORM_DATA;
34  import static org.forgerock.json.resource.http.HttpUtils.PARAM_ACTION;
35  import static org.forgerock.json.resource.http.HttpUtils.PARAM_FIELDS;
36  import static org.forgerock.json.resource.http.HttpUtils.PARAM_MIME_TYPE;
37  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_COOKIE;
38  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGED_RESULTS_OFFSET;
39  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PAGE_SIZE;
40  import static org.forgerock.json.resource.http.HttpUtils.PARAM_PRETTY_PRINT;
41  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_EXPRESSION;
42  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_FILTER;
43  import static org.forgerock.json.resource.http.HttpUtils.PARAM_QUERY_ID;
44  import static org.forgerock.json.resource.http.HttpUtils.PARAM_SORT_KEYS;
45  import static org.forgerock.json.resource.http.HttpUtils.PARAM_TOTAL_PAGED_RESULTS_POLICY;
46  import static org.forgerock.json.resource.http.HttpUtils.PROTOCOL_VERSION_1;
47  import static org.forgerock.json.resource.http.HttpUtils.RESTRICTED_HEADER_NAMES;
48  import static org.forgerock.json.resource.http.HttpUtils.SORT_KEYS_DELIMITER;
49  import static org.forgerock.json.resource.http.HttpUtils.asBooleanValue;
50  import static org.forgerock.json.resource.http.HttpUtils.asIntValue;
51  import static org.forgerock.json.resource.http.HttpUtils.asSingleValue;
52  import static org.forgerock.json.resource.http.HttpUtils.determineRequestType;
53  import static org.forgerock.json.resource.http.HttpUtils.fail;
54  import static org.forgerock.json.resource.http.HttpUtils.getIfMatch;
55  import static org.forgerock.json.resource.http.HttpUtils.getIfNoneMatch;
56  import static org.forgerock.json.resource.http.HttpUtils.getJsonActionContent;
57  import static org.forgerock.json.resource.http.HttpUtils.getJsonContent;
58  import static org.forgerock.json.resource.http.HttpUtils.getJsonPatchContent;
59  import static org.forgerock.json.resource.http.HttpUtils.getMethod;
60  import static org.forgerock.json.resource.http.HttpUtils.getParameter;
61  import static org.forgerock.json.resource.http.HttpUtils.getRequestedResourceVersion;
62  import static org.forgerock.json.resource.http.HttpUtils.rejectIfMatch;
63  import static org.forgerock.json.resource.http.HttpUtils.rejectIfNoneMatch;
64  import static org.forgerock.json.resource.http.HttpUtils.staticContextFactory;
65  import static org.forgerock.util.Reject.checkNotNull;
66  import static org.forgerock.util.promise.Promises.newResultPromise;
67  import static org.wrensecurity.guava.common.base.Optional.absent;
68  import static org.wrensecurity.guava.common.base.Strings.isNullOrEmpty;
69  
70  import com.fasterxml.jackson.databind.ObjectMapper;
71  import com.fasterxml.jackson.databind.ObjectWriter;
72  import io.swagger.v3.oas.models.OpenAPI;
73  import io.swagger.v3.oas.models.PathItem;
74  import io.swagger.v3.oas.models.Paths;
75  import java.util.ArrayList;
76  import java.util.Collections;
77  import java.util.List;
78  import java.util.Map;
79  import java.util.concurrent.CopyOnWriteArrayList;
80  import java.util.concurrent.ExecutionException;
81  import org.forgerock.api.CrestApiProducer;
82  import org.forgerock.api.jackson.PathsModule;
83  import org.forgerock.api.models.ApiDescription;
84  import org.forgerock.api.transform.OpenApiTransformer;
85  import org.forgerock.http.ApiProducer;
86  import org.forgerock.http.Handler;
87  import org.forgerock.http.header.AcceptLanguageHeader;
88  import org.forgerock.http.header.ContentTypeHeader;
89  import org.forgerock.http.protocol.Form;
90  import org.forgerock.http.protocol.Response;
91  import org.forgerock.http.protocol.Status;
92  import org.forgerock.http.routing.UriRouterContext;
93  import org.forgerock.http.routing.Version;
94  import org.forgerock.http.util.Json;
95  import org.forgerock.http.util.Uris;
96  import org.forgerock.json.JsonValue;
97  import org.forgerock.json.resource.ActionRequest;
98  import org.forgerock.json.resource.AdviceContext;
99  import org.forgerock.json.resource.BadRequestException;
100 import org.forgerock.json.resource.ConflictException;
101 import org.forgerock.json.resource.Connection;
102 import org.forgerock.json.resource.ConnectionFactory;
103 import org.forgerock.json.resource.CountPolicy;
104 import org.forgerock.json.resource.CreateRequest;
105 import org.forgerock.json.resource.CrestApplication;
106 import org.forgerock.json.resource.DeleteRequest;
107 import org.forgerock.json.resource.NotSupportedException;
108 import org.forgerock.json.resource.PatchRequest;
109 import org.forgerock.json.resource.PreconditionFailedException;
110 import org.forgerock.json.resource.QueryFilters;
111 import org.forgerock.json.resource.QueryRequest;
112 import org.forgerock.json.resource.ReadRequest;
113 import org.forgerock.json.resource.Request;
114 import org.forgerock.json.resource.RequestType;
115 import org.forgerock.json.resource.Requests;
116 import org.forgerock.json.resource.ResourceException;
117 import org.forgerock.json.resource.ResourcePath;
118 import org.forgerock.json.resource.UpdateRequest;
119 import org.forgerock.services.context.Context;
120 import org.forgerock.services.context.RootContext;
121 import org.forgerock.services.descriptor.Describable;
122 import org.forgerock.util.AsyncFunction;
123 import org.forgerock.util.i18n.PreferredLocales;
124 import org.forgerock.util.promise.NeverThrowsException;
125 import org.forgerock.util.promise.Promise;
126 import org.slf4j.Logger;
127 import org.slf4j.LoggerFactory;
128 import org.wrensecurity.guava.common.base.Optional;
129 import org.wrensecurity.guava.common.cache.CacheBuilder;
130 import org.wrensecurity.guava.common.cache.CacheLoader;
131 import org.wrensecurity.guava.common.cache.LoadingCache;
132 import org.wrensecurity.guava.common.util.concurrent.UncheckedExecutionException;
133 
134 /**
135  * HTTP adapter from HTTP calls to JSON resource calls. This class can be
136  * used in any {@link org.forgerock.http.Handler}, just create a new instance and override the handle(Context, Request)
137  * method in your HTTP Handler to delegate all those calls to this class's handle(Context, Request)
138  * method.
139  * <p>
140  * For example:
141  *
142  * <pre>
143  * public class TestHandler extends org.forgerock.http.Handler {
144  *     private final  HttpAdapter adapter;
145  *
146  *     public TestHandler() {
147  *         RequestHandler handler = xxx;
148  *         ConnectionFactory connectionFactory =
149  *                 Resources.newInternalConnectionFactory(handler);
150  *         adapter = new HttpAdapter(connectionFactory);
151  *     }
152  *
153  *     protected Promise<Response, ResponseException> handler(Context context,
154  *                 org.forgerock.http.Request req)
155  *             throws ResponseException {
156  *         return adapter.handle(context, req);
157  *     }
158  * }
159  * </pre>
160  *
161  * Note that this adapter does not provide implementations for the HTTP HEAD,
162  * OPTIONS, or TRACE methods. A simpler approach is to use the
163  * {@link CrestHttp} class contained within this package to build HTTP
164  * Handlers since it provides support for these HTTP methods.
165  */
166 final class HttpAdapter implements Handler, Describable<OpenAPI, org.forgerock.http.protocol.Request>,
167         Describable.Listener {
168 
169     private static final Logger logger = LoggerFactory.getLogger(HttpAdapter.class);
170     private static final ObjectMapper API_OBJECT_MAPPER = new ObjectMapper().registerModules(
171             new Json.LocalizableStringModule(),
172             new Json.JsonValueModule(),
173             new PathsModule());
174 
175     private final ConnectionFactory connectionFactory;
176     private final HttpContextFactory contextFactory;
177     private final String apiId;
178     private final String apiVersion;
179     private final List<Describable.Listener> apiListeners = new CopyOnWriteArrayList<>();
180     private ApiProducer<OpenAPI> apiProducer;
181     private LoadingCache<String, Optional<OpenAPI>> descriptorCache;
182 
183     /**
184      * Creates a new HTTP adapter with the provided connection factory and a
185      * context factory the {@link SecurityContextFactory}.
186      *
187      * @param connectionFactory
188      *            The connection factory.
189      * @deprecated Use {@link CrestHttp#newHttpHandler(CrestApplication)} instead.
190      */
191     @Deprecated
192     public HttpAdapter(ConnectionFactory connectionFactory) {
193         this(connectionFactory, (HttpContextFactory) null);
194     }
195 
196     /**
197      * Creates a new HTTP adapter with the provided connection factory and
198      * parent request context.
199      *
200      * @param connectionFactory
201      *            The connection factory.
202      * @param parentContext
203      *            The parent request context which should be used as the parent
204      *            context of each request context.
205      * @deprecated Use {@link CrestHttp#newHttpHandler(CrestApplication, Context)} instead.
206      */
207     @SuppressWarnings("deprecation")
208     @Deprecated
209     public HttpAdapter(ConnectionFactory connectionFactory, final Context parentContext) {
210         this(connectionFactory, staticContextFactory(parentContext));
211     }
212 
213     /**
214      * Creates a new HTTP adapter with the provided connection factory and
215      * context factory.
216      *
217      * @param connectionFactory
218      *            The connection factory.
219      * @param contextFactory
220      *            The context factory which will be used to obtain the parent
221      *            context of each request context, or {@code null} if the
222      *            {@link SecurityContextFactory} should be used.
223      * @deprecated Use {@link #HttpAdapter(CrestApplication, HttpContextFactory)} instead
224      */
225     @SuppressWarnings("deprecation")
226     @Deprecated
227     public HttpAdapter(ConnectionFactory connectionFactory, HttpContextFactory contextFactory) {
228         this(simpleCrestApplication(connectionFactory, null, null), contextFactory);
229     }
230 
231     /**
232      * Creates a new HTTP adapter with the provided connection factory and
233      * context factory.
234      *
235      * @param application
236      *            The CREST application.
237      * @param contextFactory
238      *            The context factory which will be used to obtain the parent
239      *            context of each request context, or {@code null} if the
240      *            {@link SecurityContextFactory} should be used.
241      */
242     @SuppressWarnings("deprecation")
243     public HttpAdapter(CrestApplication application, HttpContextFactory contextFactory) {
244         this.contextFactory = contextFactory != null ? contextFactory : SecurityContextFactory
245                 .getHttpServletContextFactory();
246         this.connectionFactory = checkNotNull(application.getConnectionFactory());
247         this.apiId = application.getApiId();
248         this.apiVersion = application.getApiVersion();
249 
250         try {
251             Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
252             if (describable.isPresent()) {
253                 describable.get().addDescriptorListener(this);
254             }
255         } catch (ResourceException e) {
256             logger.warn("Could not create connection", e);
257         }
258 
259     }
260 
261     /**
262      * Handles the incoming HTTP request and converts it to a CREST request.
263      *
264      * @param context {@inheritDoc}
265      * @param request {@inheritDoc}
266      * @return Promise containing a {@code Response} or {@code ResponseException}.
267      */
268     @Override
269     public Promise<Response, NeverThrowsException> handle(Context context,
270             org.forgerock.http.protocol.Request request) {
271         try {
272             RequestType requestType = determineRequestType(request);
273             switch (requestType) {
274             case CREATE:
275                 return doCreate(context, request);
276             case READ:
277                 return doRead(context, request);
278             case UPDATE:
279                 return doUpdate(context, request);
280             case DELETE:
281                 return doDelete(context, request);
282             case PATCH:
283                 return doPatch(context, request);
284             case ACTION:
285                 return doAction(context, request);
286             case QUERY:
287                 return doQuery(context, request);
288             case API:
289                 return doApiRequest(context, request);
290             default:
291                 throw new NotSupportedException("Operation " + requestType + " not supported");
292             }
293         } catch (ResourceException e) {
294             return fail(request, e);
295         }
296     }
297 
298     Promise<Response, NeverThrowsException> doDelete(Context context, org.forgerock.http.protocol.Request req) {
299         try {
300             Version requestedResourceVersion = getRequestedResourceVersion(req);
301 
302             // Validate request.
303             preprocessRequest(req);
304             rejectIfNoneMatch(req);
305 
306             // use the version-1 meaning of getIfMatch; i.e., treat * as null
307             final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
308             final Form parameters = req.getForm();
309             final DeleteRequest request =
310                     Requests.newDeleteRequest(getResourcePath(context, req))
311                             .setRevision(ifMatchRevision)
312                             .setResourceVersion(requestedResourceVersion);
313             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
314                 final String name = p.getKey();
315                 final List<String> values = p.getValue();
316                 if (parseCommonParameter(name, values, request)) {
317                     continue;
318                 } else {
319                     request.setAdditionalParameter(name, asSingleValue(name, values));
320                 }
321             }
322             return doRequest(context, req, request);
323         } catch (final Exception e) {
324             return fail(req, e);
325         }
326     }
327 
328     Promise<Response, NeverThrowsException> doRead(Context context, org.forgerock.http.protocol.Request req) {
329         try {
330             Version requestedResourceVersion = getRequestedResourceVersion(req);
331 
332             // Validate request.
333             preprocessRequest(req);
334             rejectIfMatch(req);
335 
336             final Form parameters = req.getForm();
337             // Read of instance within collection or singleton.
338             final String rev = getIfNoneMatch(req);
339             if (ETAG_ANY.equals(rev)) {
340                 // FIXME: i18n
341                 throw new PreconditionFailedException("If-None-Match * not appropriate for "
342                         + getMethod(req) + " requests");
343             }
344 
345             final ReadRequest request = Requests.newReadRequest(getResourcePath(context, req))
346                     .setResourceVersion(requestedResourceVersion);
347             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
348                 final String name = p.getKey();
349                 final List<String> values = p.getValue();
350                 if (parseCommonParameter(name, values, request)) {
351                     continue;
352                 } else if (PARAM_MIME_TYPE.equalsIgnoreCase(name)) {
353                     // simply ignore -> support for custom MIME type is up to handler implementation
354                 } else {
355                     request.setAdditionalParameter(name, asSingleValue(name, values));
356                 }
357             }
358             return doRequest(context, req, request);
359         } catch (final Exception e) {
360             return fail(req, e);
361         }
362     }
363 
364     Promise<Response, NeverThrowsException> doQuery(Context context, org.forgerock.http.protocol.Request req) {
365         try {
366             Version requestedResourceVersion = getRequestedResourceVersion(req);
367 
368             // Validate request.
369             preprocessRequest(req);
370             rejectIfMatch(req);
371 
372             final Form parameters = req.getForm();
373             // Additional pre-validation for queries.
374             rejectIfNoneMatch(req);
375 
376             // Query against collection.
377             final QueryRequest request = Requests.newQueryRequest(getResourcePath(context, req))
378                     .setResourceVersion(requestedResourceVersion);
379 
380             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
381                 final String name = p.getKey();
382                 final List<String> values = p.getValue();
383 
384                 if (parseCommonParameter(name, values, request)) {
385                     continue;
386                 } else if (name.equalsIgnoreCase(PARAM_SORT_KEYS)) {
387                     for (final String s : values) {
388                         try {
389                             request.addSortKey(s.split(SORT_KEYS_DELIMITER));
390                         } catch (final IllegalArgumentException e) {
391                             // FIXME: i18n.
392                             throw new BadRequestException("The value '" + s
393                                     + "' for parameter '" + name
394                                     + "' could not be parsed as a comma "
395                                     + "separated list of sort keys");
396                         }
397                     }
398                 } else if (name.equalsIgnoreCase(PARAM_QUERY_ID)) {
399                     request.setQueryId(asSingleValue(name, values));
400                 } else if (name.equalsIgnoreCase(PARAM_QUERY_EXPRESSION)) {
401                     request.setQueryExpression(asSingleValue(name, values));
402                 } else if (name.equalsIgnoreCase(PARAM_PAGED_RESULTS_COOKIE)) {
403                     request.setPagedResultsCookie(asSingleValue(name, values));
404                 } else if (name.equalsIgnoreCase(PARAM_PAGED_RESULTS_OFFSET)) {
405                     request.setPagedResultsOffset(asIntValue(name, values));
406                 } else if (name.equalsIgnoreCase(PARAM_PAGE_SIZE)) {
407                     request.setPageSize(asIntValue(name, values));
408                 } else if (name.equalsIgnoreCase(PARAM_QUERY_FILTER)) {
409                     final String s = asSingleValue(name, values);
410                     try {
411                         request.setQueryFilter(QueryFilters.parse(s));
412                     } catch (final IllegalArgumentException e) {
413                         // FIXME: i18n.
414                         throw new BadRequestException("The value '" + s + "' for parameter '"
415                                 + name + "' could not be parsed as a valid query filter");
416                     }
417                 } else if (name.equalsIgnoreCase(PARAM_TOTAL_PAGED_RESULTS_POLICY)) {
418                     final String policy = asSingleValue(name, values);
419 
420                     try {
421                         request.setTotalPagedResultsPolicy(CountPolicy.valueOf(policy.toUpperCase()));
422                     } catch (IllegalArgumentException e) {
423                         // FIXME: i18n.
424                         throw new BadRequestException("The value '" + policy + "' for parameter '"
425                                 + name + "' could not be parsed as a valid count policy");
426                     }
427                 } else {
428                     request.setAdditionalParameter(name, asSingleValue(name, values));
429                 }
430             }
431 
432             // Check for incompatible arguments.
433             if (request.getQueryId() != null && request.getQueryFilter() != null) {
434                 // FIXME: i18n.
435                 throw new BadRequestException("The parameters " + PARAM_QUERY_ID + " and "
436                         + PARAM_QUERY_FILTER + " are mutually exclusive");
437             }
438 
439             if (request.getQueryId() != null && request.getQueryExpression() != null) {
440                 // FIXME: i18n.
441                 throw new BadRequestException("The parameters " + PARAM_QUERY_ID + " and "
442                         + PARAM_QUERY_EXPRESSION + " are mutually exclusive");
443             }
444 
445             if (request.getQueryFilter() != null && request.getQueryExpression() != null) {
446                 // FIXME: i18n.
447                 throw new BadRequestException("The parameters " + PARAM_QUERY_FILTER + " and "
448                         + PARAM_QUERY_EXPRESSION + " are mutually exclusive");
449             }
450 
451             if (request.getPagedResultsOffset() > 0 && request.getPagedResultsCookie() != null) {
452                 // FIXME: i18n.
453                 throw new BadRequestException("The parameters " + PARAM_PAGED_RESULTS_OFFSET + " and "
454                         + PARAM_PAGED_RESULTS_COOKIE + " are mutually exclusive");
455             }
456 
457             return doRequest(context, req, request);
458         } catch (final Exception e) {
459             return fail(req, e);
460         }
461     }
462 
463     Promise<Response, NeverThrowsException> doPatch(Context context, org.forgerock.http.protocol.Request req) {
464         try {
465             Version requestedResourceVersion = getRequestedResourceVersion(req);
466 
467             // Validate request.
468             preprocessRequest(req);
469             if (req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
470                 // FIXME: i18n
471                 throw new PreconditionFailedException(
472                         "Use of If-None-Match not supported for PATCH requests");
473             }
474 
475             // use the version 1 meaning of getIfMatch; i.e., treat * as null
476             final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
477             final Form parameters = req.getForm();
478             final PatchRequest request =
479                     Requests.newPatchRequest(getResourcePath(context, req))
480                             .setRevision(ifMatchRevision)
481                             .setResourceVersion(requestedResourceVersion);
482             request.getPatchOperations().addAll(getJsonPatchContent(req));
483             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
484                 final String name = p.getKey();
485                 final List<String> values = p.getValue();
486                 if (parseCommonParameter(name, values, request)) {
487                     continue;
488                 } else {
489                     request.setAdditionalParameter(name, asSingleValue(name, values));
490                 }
491             }
492             return doRequest(context, req, request);
493         } catch (final Exception e) {
494             return fail(req, e);
495         }
496     }
497 
498     Promise<Response, NeverThrowsException> doCreate(Context context, org.forgerock.http.protocol.Request req) {
499         try {
500             Version requestedResourceVersion = getRequestedResourceVersion(req);
501 
502             // Validate request.
503             preprocessRequest(req);
504 
505             if ("POST".equals(getMethod(req))) {
506 
507                 rejectIfNoneMatch(req);
508                 rejectIfMatch(req);
509 
510                 final Form parameters = req.getForm();
511                 final JsonValue content = getJsonContent(req);
512                 final CreateRequest request =
513                         Requests.newCreateRequest(getResourcePath(context, req), content)
514                                 .setResourceVersion(requestedResourceVersion);
515                 for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
516                     final String name = p.getKey();
517                     final List<String> values = p.getValue();
518                     if (parseCommonParameter(name, values, request)) {
519                         continue;
520                     } else if (name.equalsIgnoreCase(PARAM_ACTION)) {
521                         // Ignore - already handled.
522                     } else {
523                         request.setAdditionalParameter(name, asSingleValue(name, values));
524                     }
525                 }
526                 return doRequest(context, req, request);
527             } else {
528 
529                 if (req.getHeaders().getFirst(HEADER_IF_MATCH) != null
530                         && req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
531                     // FIXME: i18n
532                     throw new PreconditionFailedException(
533                             "Simultaneous use of If-Match and If-None-Match not supported for PUT requests");
534                 }
535 
536                 final Form parameters = req.getForm();
537                 final JsonValue content = getJsonContent(req);
538 
539                 // This is a create with a user provided resource ID: split the
540                 // path into the parent resource name and resource ID.
541                 final ResourcePath resourcePath = getResourcePath(context, req);
542                 if (resourcePath.isEmpty()) {
543                     // FIXME: i18n.
544                     throw new BadRequestException("No new resource ID in HTTP PUT request");
545                 }
546 
547                 // We have a pathInfo of the form "{container}/{id}"
548                 final CreateRequest request =
549                         Requests.newCreateRequest(resourcePath.parent(), content)
550                                 .setNewResourceId(resourcePath.leaf())
551                                 .setResourceVersion(requestedResourceVersion);
552                 for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
553                     final String name = p.getKey();
554                     final List<String> values = p.getValue();
555                     if (parseCommonParameter(name, values, request)) {
556                         continue;
557                     } else {
558                         request.setAdditionalParameter(name, asSingleValue(name, values));
559                     }
560                 }
561                 return doRequest(context, req, request);
562             }
563         } catch (final Exception e) {
564             return fail(req, e);
565         }
566     }
567 
568     Promise<Response, NeverThrowsException> doAction(Context context, org.forgerock.http.protocol.Request req) {
569         try {
570             Version requestedResourceVersion = getRequestedResourceVersion(req);
571 
572             // Validate request.
573             preprocessRequest(req);
574             rejectIfNoneMatch(req);
575             rejectIfMatch(req);
576 
577             final Form parameters = req.getForm();
578             final String action = asSingleValue(PARAM_ACTION, getParameter(req, PARAM_ACTION));
579             // Action request.
580             final JsonValue content = getJsonActionContent(req);
581             final ActionRequest request =
582                     Requests.newActionRequest(getResourcePath(context, req), action)
583                             .setContent(content)
584                             .setResourceVersion(requestedResourceVersion);
585             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
586                 final String name = p.getKey();
587                 final List<String> values = p.getValue();
588                 if (parseCommonParameter(name, values, request)) {
589                     continue;
590                 } else if (name.equalsIgnoreCase(PARAM_ACTION)) {
591                     // Ignore - already handled.
592                 } else {
593                     request.setAdditionalParameter(name, asSingleValue(name, values));
594                 }
595             }
596             return doRequest(context, req, request);
597         } catch (final Exception e) {
598             return fail(req, e);
599         }
600     }
601 
602     Promise<Response, NeverThrowsException> doUpdate(Context context, org.forgerock.http.protocol.Request req) {
603         try {
604             Version requestedResourceVersion = getRequestedResourceVersion(req);
605 
606             // Validate request.
607             preprocessRequest(req);
608 
609             if (req.getHeaders().getFirst(HEADER_IF_MATCH) != null
610                     && req.getHeaders().getFirst(HEADER_IF_NONE_MATCH) != null) {
611                 // FIXME: i18n
612                 throw new PreconditionFailedException(
613                         "Simultaneous use of If-Match and If-None-Match not supported for PUT requests");
614             }
615 
616             // use the version 1 meaning of getIfMatch; i.e., treat * as null
617             final String ifMatchRevision = getIfMatch(req, PROTOCOL_VERSION_1);
618             final Form parameters = req.getForm();
619             final JsonValue content = getJsonContent(req);
620 
621             final UpdateRequest request =
622                     Requests.newUpdateRequest(getResourcePath(context, req), content)
623                             .setRevision(ifMatchRevision)
624                             .setResourceVersion(requestedResourceVersion);
625             for (final Map.Entry<String, List<String>> p : parameters.entrySet()) {
626                 final String name = p.getKey();
627                 final List<String> values = p.getValue();
628                 if (parseCommonParameter(name, values, request)) {
629                     continue;
630                 } else {
631                     request.setAdditionalParameter(name, asSingleValue(name, values));
632                 }
633             }
634             return doRequest(context, req, request);
635         } catch (final Exception e) {
636             return fail(req, e);
637         }
638     }
639 
640     private Promise<Response, NeverThrowsException> doApiRequest(Context context,
641             final org.forgerock.http.protocol.Request req) {
642         try {
643             Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
644             if (!describable.isPresent()) {
645                 throw new NotSupportedException();
646             }
647             Request request = newApiRequest(getResourcePath(context, req));
648             context = prepareRequest(context, req, request);
649             ApiDescription api = describable.get().handleApiRequest(context, request);
650 
651             ObjectWriter writer = Json.makeLocalizingObjectWriter(API_OBJECT_MAPPER, request.getPreferredLocales());
652 
653             // Enable pretty printer if requested.
654             final List<String> values = getParameter(req, PARAM_PRETTY_PRINT);
655             if (values != null) {
656                 if (asBooleanValue(PARAM_PRETTY_PRINT, values)) {
657                     writer = writer.withDefaultPrettyPrinter();
658                 }
659             }
660 
661             return newResultPromise(new Response(Status.OK).setEntity(writer.writeValueAsBytes(api)));
662         } catch (Exception e) {
663             return fail(req, e);
664         }
665     }
666 
667     @SuppressWarnings("unchecked")
668     private Optional<Describable<ApiDescription, Request>> getDescribableConnection()
669             throws ResourceException {
670         if (apiId == null || apiVersion == null) {
671             logger.info("CREST API Descriptor API ID and Version are not set. Not describing.");
672             return absent();
673         }
674         Connection connection = connectionFactory.getConnection();
675         if (connection instanceof Describable) {
676             return Optional.of((Describable<ApiDescription, Request>) connection);
677         } else {
678             return absent();
679         }
680     }
681 
682     private Promise<Response, NeverThrowsException> doRequest(Context context, org.forgerock.http.protocol.Request req,
683             Request request) throws Exception {
684         Context ctx = prepareRequest(context, req, request);
685         final RequestRunner runner = new RequestRunner(ctx, request, req, new Response(Status.OK));
686         return connectionFactory.getConnectionAsync()
687                 .thenAsync(new AsyncFunction<Connection, Response, NeverThrowsException>() {
688                     @Override
689                     public Promise<Response, NeverThrowsException> apply(Connection connection) {
690                         return runner.handleResult(connection);
691                     }
692                 }, new AsyncFunction<ResourceException, Response, NeverThrowsException>() {
693                     @Override
694                     public Promise<Response, NeverThrowsException> apply(ResourceException error) {
695                         return runner.handleError(error);
696                     }
697                 });
698     }
699 
700     private Context prepareRequest(Context context, org.forgerock.http.protocol.Request req, Request request)
701             throws ResourceException, org.forgerock.http.header.MalformedHeaderException {
702         Context ctx = newRequestContext(context, req);
703         final AcceptLanguageHeader acceptLanguageHeader = req.getHeaders().get(AcceptLanguageHeader.class);
704         request.setPreferredLocales(acceptLanguageHeader != null
705                 ? acceptLanguageHeader.getLocales()
706                 : new PreferredLocales(null));
707         return ctx;
708     }
709 
710     /**
711      * Gets the raw (still url-encoded) resource name from the request. Removes leading and trailing forward slashes.
712      */
713     private ResourcePath getResourcePath(Context context, org.forgerock.http.protocol.Request req)
714             throws ResourceException {
715         try {
716             if (context.containsContext(UriRouterContext.class)) {
717                 ResourcePath reqPath = ResourcePath.valueOf(req.getUri().getRawPath());
718                 return reqPath.subSequence(getMatchedUri(context).size(), reqPath.size());
719             } else {
720                 return ResourcePath.valueOf(req.getUri().getRawPath()); //TODO is this a valid assumption?
721             }
722         } catch (IllegalArgumentException e) {
723             throw new BadRequestException(e.getMessage());
724         }
725     }
726 
727     private ResourcePath getMatchedUri(Context context) {
728         List<ResourcePath> matched = new ArrayList<>();
729         Context ctx = context;
730         while (ctx.containsContext(UriRouterContext.class)) {
731             UriRouterContext uriRouterContext = ctx.asContext(UriRouterContext.class);
732             matched.add(ResourcePath.valueOf(uriRouterContext.getMatchedUri()));
733             ctx = uriRouterContext.getParent();
734         }
735         Collections.reverse(matched);
736         ResourcePath matchedUri = new ResourcePath();
737         for (ResourcePath resourcePath : matched) {
738             matchedUri = matchedUri.concat(resourcePath);
739         }
740         return matchedUri;
741     }
742 
743     private Context newRequestContext(Context context, org.forgerock.http.protocol.Request req)
744             throws ResourceException {
745         final Context parent = contextFactory.createContext(context, req);
746         return new AdviceContext(new HttpContext(parent, req), RESTRICTED_HEADER_NAMES);
747     }
748 
749     private boolean parseCommonParameter(final String name, final List<String> values,
750             final Request request) throws ResourceException {
751         if (name.equalsIgnoreCase(PARAM_FIELDS)) {
752             for (final String s : values) {
753                 try {
754                     request.addField(s.split(","));
755                 } catch (final IllegalArgumentException e) {
756                     // FIXME: i18n.
757                     throw new BadRequestException("The value '" + s + "' for parameter '" + name
758                             + "' could not be parsed as a comma separated list of JSON pointers");
759                 }
760             }
761             return true;
762         } else if (name.equalsIgnoreCase(PARAM_PRETTY_PRINT)) {
763             // This will be handled by the completionHandlerFactory, so just validate.
764             asBooleanValue(name, values);
765             return true;
766         } else {
767             // Unrecognized - must be request specific.
768             return false;
769         }
770     }
771 
772     private void preprocessRequest(org.forgerock.http.protocol.Request req) throws ResourceException {
773         // TODO: check Accept (including charset parameter) and Accept-Charset headers
774 
775         // Check content-type.
776         final String contentType = ContentTypeHeader.valueOf(req).getType();
777         if (!req.getMethod().equalsIgnoreCase(HttpUtils.METHOD_GET)
778                 && contentType != null
779                 && !CONTENT_TYPE_REGEX.matcher(contentType).matches()
780                 && !HttpUtils.isMultiPartRequest(contentType)) {
781             // TODO: i18n
782             throw new BadRequestException(
783                     "The request could not be processed because it specified the content-type '"
784                             + contentType + "' when only the content-type '"
785                             + MIME_TYPE_APPLICATION_JSON + "' and '"
786                             + MIME_TYPE_MULTIPART_FORM_DATA + "' are supported");
787         }
788 
789         if (req.getHeaders().getFirst(HEADER_IF_MODIFIED_SINCE) != null) {
790             // TODO: i18n
791             throw new ConflictException("Header If-Modified-Since not supported");
792         }
793 
794         if (req.getHeaders().getFirst(HEADER_IF_UNMODIFIED_SINCE) != null) {
795             // TODO: i18n
796             throw new ConflictException("Header If-Unmodified-Since not supported");
797         }
798     }
799 
800     @Override
801     public OpenAPI api(ApiProducer<OpenAPI> producer) {
802         this.apiProducer = producer;
803         return updateDescriptor();
804     }
805 
806     private OpenAPI updateDescriptor() {
807         if (apiProducer == null) {
808             // Not yet attached to CHF
809             return null;
810         }
811         try {
812             Optional<Describable<ApiDescription, Request>> describable = getDescribableConnection();
813             if (describable.isPresent()) {
814                 ApiDescription api = describable.get().api(new CrestApiProducer(apiId, apiVersion));
815                 if (api != null) {
816                     this.descriptorCache = CacheBuilder.newBuilder().expireAfterAccess(30, MINUTES)
817                             .build(new CacheLoader<String, Optional<OpenAPI>>() {
818                                 @Override
819                                 public Optional<OpenAPI> load(String uri) throws ResourceException {
820                                     UriRouterContext context = new UriRouterContext(new RootContext(), "", uri,
821                                             Collections.<String, String>emptyMap());
822                                     ApiDescription api = getDescribableConnection().get()
823                                             .handleApiRequest(context, newApiRequest(resourcePath(uri)));
824                                     // Avoid NPE later during transformation
825                                     if (api == null) {
826                                         return absent();
827                                     }
828                                     OpenAPI openApi = OpenApiTransformer.execute(api, COMMONS_API_DESCRIPTION);
829                                     uri = removeTrailingSlash(uri);
830                                     if (!isNullOrEmpty(uri)) {
831                                         uri = addLeadingSlash(Uris.urlDecodePathElement(uri));
832                                     }
833                                     Paths paths = new Paths();
834                                     if (openApi.getPaths() != null) {
835                                         for (Map.Entry<String, PathItem> path : openApi.getPaths().entrySet()) {
836                                             String pathString = path.getKey();
837                                             // A path will always start with a slash.
838                                             // Remove leading slash only if it is also the end of the path
839                                             if ((pathString.startsWith("/#") || pathString.equals("/"))
840                                                     && !uri.isEmpty()) {
841                                                 pathString = pathString.substring(1);
842                                             }
843                                             paths.addPathItem(uri + pathString, path.getValue());
844                                         }
845                                     }
846                                     openApi.setPaths(paths);
847                                     return Optional.of(apiProducer.addApiInfo(openApi));
848                                 }
849                             });
850                     try {
851                         return descriptorCache.get("").orNull();
852                     } catch (ExecutionException e) {
853                         throw (ResourceException) e.getCause();
854                     }
855                 }
856             }
857         } catch (ResourceException e) {
858             throw new IllegalStateException("Cannot get connection", e);
859         }
860         return null;
861     }
862 
863     @Override
864     public OpenAPI handleApiRequest(Context context, org.forgerock.http.protocol.Request request) {
865         if (descriptorCache == null) {
866             return null;
867         }
868         Optional<OpenAPI> result;
869         try {
870             if (context.containsContext(UriRouterContext.class)) {
871                 result = descriptorCache.get(context.asContext(UriRouterContext.class).getRemainingUri());
872             } else {
873                 result = descriptorCache.get("");
874             }
875         } catch (ExecutionException e) {
876             throw new UnsupportedOperationException("Cannot get connection", e);
877         } catch (UncheckedExecutionException e) {
878             throw (RuntimeException) e.getCause();
879         }
880         return result.orNull();
881     }
882 
883     @Override
884     public void addDescriptorListener(Listener listener) {
885         apiListeners.add(listener);
886     }
887 
888     @Override
889     public void removeDescriptorListener(Listener listener) {
890         apiListeners.remove(listener);
891     }
892 
893     @Override
894     public void notifyDescriptorChange() {
895         updateDescriptor();
896         for (Listener listener : apiListeners) {
897             listener.notifyDescriptorChange();
898         }
899     }
900 }