001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.processor;
018
019import static org.apache.juneau.commons.utils.StringUtils.*;
020
021import java.io.*;
022import java.util.*;
023import java.util.stream.*;
024
025import org.apache.juneau.bean.rfc7807.*;
026import org.apache.juneau.bean.rfc7807.adapter.*;
027import org.apache.juneau.http.*;
028import org.apache.juneau.http.header.*;
029import org.apache.juneau.http.response.*;
030import org.apache.juneau.marshall.marshaller.*;
031import org.apache.juneau.marshall.serializer.*;
032import org.apache.juneau.rest.server.*;
033
034/**
035 * Response processor that serializes
036 * <a class="doclink" href="https://www.rfc-editor.org/rfc/rfc7807">RFC 7807 / 9457</a> {@link Problem} payloads as
037 * {@code application/problem+json}.
038 *
039 * <p>
040 * Fires when the response content is one of the following (in order):
041 * <ul class='spaced-list'>
042 *    <li>A {@link Problem} bean &mdash; serialized directly. Honors the client {@code Accept} header.
043 *    <li>A {@link ProblemException} &mdash; unwrapped via {@link ProblemException#getProblem()}. Honors the
044 *       client {@code Accept} header.
045 *    <li>A {@link BasicHttpException} &mdash; adapted via a registered {@link ProblemMapper} (if any matches),
046 *       else via {@link ProblemAdapters#fromException(BasicHttpException)}. This branch handles the common throw
047 *       path where the operation handler throws a {@code BasicHttpException} and the framework sets it as the
048 *       response content. <b>Gated by {@code isProblemDetails()}</b> on the {@link RestOpContext} (per-op
049 *       {@code @RestGet(problemDetails)} / {@code @RestPost(problemDetails)} / etc., falling back to the
050 *       resource-level {@code @Rest(problemDetails)} opt-in via tri-state inheritance). When opted-in, the
051 *       {@code Accept} header is ignored on this branch (errors always emit {@code application/problem+json}; see
052 *       RFC 7807).
053 *    <li>Any other {@link Throwable} &mdash; only when an explicit {@link ProblemMapper} matches its class
054 *       hierarchy. Gated by the same opt-in. Mappers that return {@code null} are skipped and the chain
055 *       continues; if no mapper matches, the processor returns {@code NEXT}.
056 * </ul>
057 *
058 * <h5 class='section'>Mapper discovery</h5>
059 * <p>
060 * The processor pulls registered {@link ProblemMapper}s from the resource's bean store in this order:
061 * <ul class='spaced-list'>
062 *    <li>A {@link ProblemMapperList} bean &mdash; treated as the authoritative ordered registry. Recommended
063 *       when more than one mapper is registered, since the underlying {@code @Bean} bean-store walk pairs each
064 *       factory method with its return type and so collapses multiple
065 *       {@code @Bean public ProblemMapper foo()} entries on the same resource into a single slot.
066 *    <li>A single {@link ProblemMapper} bean (via {@code BeanStore.getBean(ProblemMapper.class)}) &mdash; the
067 *       conventional single-mapper registration pattern.
068 * </ul>
069 * <p>
070 * For a given thrown exception the processor walks the collected mappers and picks the one whose
071 * {@link ProblemMapper#getExceptionType()} sits closest to the thrown class in the hierarchy (most-specific
072 * wins; ties broken by registration order). The chosen mapper's {@link ProblemMapper#map(Throwable)} return
073 * value replaces the default adaptation; a {@code null} return falls through to the next-most-specific mapper,
074 * and ultimately to the built-in {@link ProblemAdapters#fromException(BasicHttpException)} fallback for
075 * {@link BasicHttpException} subclasses.
076 *
077 * <h5 class='section'>Localization seam</h5>
078 * <p>
079 * Before serializing, the processor consults the bean store for a {@link ProblemLocalizationStrategy} bean and
080 * passes the resolved {@link Problem} (and {@code RestRequest.getLocale()}) through it. The default behavior is
081 * {@link ProblemLocalizationStrategy#IDENTITY} (pass-through) &mdash; this is a future-work seam intentionally
082 * shipped without a built-in {@code Messages}/resource-bundle implementation. A {@code null} strategy return is
083 * treated as &quot;no opinion&quot; and the pre-localization {@link Problem} is used instead.
084 *
085 * <p>
086 * <b>Status code policy.</b> When {@link Problem#getStatus()} is non-{@code null}, the processor calls
087 * {@code RestResponse.setStatus(int)} with that value. Otherwise it leaves the existing status alone &mdash;
088 * {@code RestSession.run()} normalizes {@code 0} to {@code 200} so per-op default codes (e.g. {@code @RestPost}
089 * default of 200) still flow through.
090 *
091 * <p>
092 * <b>{@code type} field policy.</b> Serialized as-is. A {@code null} {@code type} is omitted from the JSON output
093 * rather than synthesized as {@code "type":"about:blank"} on the wire (preserves the absent-vs-explicit distinction).
094 *
095 * <h5 class='section'>See Also:</h5><ul>
096 *    <li class='jc'>{@link Problem}
097 *    <li class='jc'>{@link ProblemException}
098 *    <li class='jc'>{@link ProblemMapper}
099 *    <li class='jc'>{@link ProblemLocalizationStrategy}
100 *    <li class='jc'>{@link ProblemAdapters}
101 *    <li class='link'><a class="doclink" href="https://juneau.apache.org/docs/topics/ResponseProcessors">Response Processors</a>
102 * </ul>
103 */
104@SuppressWarnings({
105   "resource" // The negotiated output stream from the response is owned and closed by the servlet container, not by this processor
106})
107public class ProblemDetailsProcessor implements ResponseProcessor {
108
109   private static final String PROBLEM_JSON = ContentType.APPLICATION_PROBLEM_JSON.getValue();
110
111   @Override /* Overridden from ResponseProcessor */
112   @SuppressWarnings({
113      "java:S3776" // Cognitive complexity acceptable for RFC-9457 problem-details response dispatch
114   })
115   public int process(RestOpSession opSession) throws IOException {
116
117      var res = opSession.getResponse();
118      var raw = res.getContent(Object.class);
119
120      if (raw == null)
121         return NEXT;
122
123      Problem problem;
124      boolean isErrorPath;
125
126      if (raw instanceof Problem raw2) {
127         problem = raw2;
128         isErrorPath = false;
129      } else if (raw instanceof ProblemException raw2) {
130         problem = raw2.getProblem();
131         isErrorPath = opSession.getContext().isProblemDetails();
132      } else if (raw instanceof BasicHttpException raw2) {
133         if (! opSession.getContext().isProblemDetails())
134            return NEXT;
135         problem = mapException(opSession, raw2);
136         isErrorPath = true;
137         // Same as the plain (HttpResponseProcessor) path: the exception's own headers (e.g. X-BeanQuery-Error) go out too.
138         if (problem != null)
139            raw2.getHeaders().forEach(res::addHeader);
140      } else if (raw instanceof Throwable raw2) {
141         if (! opSession.getContext().isProblemDetails())
142            return NEXT;
143         problem = mapException(opSession, raw2);
144         if (problem == null)
145            return NEXT;
146         isErrorPath = true;
147      } else {
148         return NEXT;
149      }
150
151      if (problem == null)
152         return NEXT;
153
154      // Q5: error path ignores Accept (RFC 7807 ยง3); success path honors Accept strictly.
155      if (! isErrorPath) {
156         var accept = opSession.getRequest().getHeaderParam("Accept").orElse("");
157         if (! acceptsProblemJson(accept))
158            return NEXT;
159      }
160
161      problem = localize(opSession, problem);
162
163      var status = problem.getStatus();
164      if (status != null)
165         res.setStatus(status.intValue());
166
167      res.setHeader(ContentType.APPLICATION_PROBLEM_JSON);
168
169      try {
170         var os = res.getNegotiatedOutputStream();
171         Json.DEFAULT.write(problem, os);
172         os.flush();
173         os.finish();
174      } catch (SerializeException e) {
175         throw new IOException("Failed to serialize Problem payload", e);
176      }
177
178      return FINISHED;
179   }
180
181   /**
182    * Resolves a {@link Problem} from a thrown exception by consulting registered {@link ProblemMapper}s in
183    * most-specific-first order, falling back to {@link ProblemAdapters#fromException(BasicHttpException)} for
184    * {@link BasicHttpException} subclasses when no mapper matches (or all matching mappers return {@code null}).
185    *
186    * @return The {@link Problem} to emit, or {@code null} when no mapper opined and the exception is not a
187    *    {@link BasicHttpException}.
188    */
189   @SuppressWarnings({
190      "rawtypes", // ProblemMapper#map is parameterized over the matched throwable subtype.
191      "unchecked" // ProblemMapper#map is parameterized over the matched throwable subtype.
192   })
193   private static Problem mapException(RestOpSession opSession, Throwable thrown) {
194      for (var mapper : resolveMappers(opSession, thrown.getClass())) {
195         var result = ((ProblemMapper) mapper).map(thrown);
196         if (result != null)
197            return result;
198      }
199      if (thrown instanceof BasicHttpException thrown2)
200         return withBeanQueryCode(ProblemAdapters.fromException(thrown2), thrown2);
201      return null;
202   }
203
204   /**
205    * Adds the RFC 7807 {@code "code"} extension member when the exception carries a
206    * {@value BeanQueryRequest#ERROR_HEADER} header (a mapped {@code BeanQuerySyntaxException}).
207    */
208   private static Problem withBeanQueryCode(Problem problem, BasicHttpException e) {
209      e.getHeaders().stream()
210         .filter(h -> BeanQueryRequest.ERROR_HEADER.equals(h.getName()))
211         .findFirst()
212         .ifPresent(h -> problem.set("code", h.getValue()));
213      return problem;
214   }
215
216   /**
217    * Returns all registered {@link ProblemMapper}s whose {@link ProblemMapper#getExceptionType()} is assignable
218    * from {@code thrownClass}, sorted most-specific-first. A {@link ProblemMapperList} bean (if present) is
219    * consulted first; a single {@link ProblemMapper} bean (if no list is registered) is consulted as a
220    * fallback. Registration order serves as the tiebreaker for mappers at the same hierarchy depth.
221    */
222   private static List<ProblemMapper<?>> resolveMappers(RestOpSession opSession, Class<?> thrownClass) {
223      var bs = opSession.getBeanStore();
224      var candidates = new ArrayList<ProblemMapper<?>>();
225      bs.getBean(ProblemMapperList.class).map(ProblemMapperList::asList).ifPresent(candidates::addAll);
226      if (candidates.isEmpty())
227         bs.getBean(ProblemMapper.class).ifPresent(candidates::add);
228      if (candidates.isEmpty())
229         return List.of();
230      var matches = candidates.stream()
231         .filter(m -> m.getExceptionType().isAssignableFrom(thrownClass))
232         .collect(Collectors.toCollection(ArrayList::new));
233      matches.sort((a, b) -> Integer.compare(hierarchyDepth(a.getExceptionType(), thrownClass), hierarchyDepth(b.getExceptionType(), thrownClass)));
234      return matches;
235   }
236
237   /**
238    * Returns the number of superclass hops from {@code thrownClass} up to {@code targetType}; lower is closer.
239    * A {@code 0} return means {@code targetType} equals {@code thrownClass} (exact match); larger values mean
240    * the mapper sits higher up the class hierarchy and is therefore less specific. Sorting ascending by this
241    * value puts the most-specific mapper first.
242    */
243   private static int hierarchyDepth(Class<?> targetType, Class<?> thrownClass) {
244      var depth = 0;
245      var c = thrownClass;
246      while (c != null && c != targetType) {
247         c = c.getSuperclass();
248         depth++;
249      }
250      return c == null ? Integer.MAX_VALUE : depth;
251   }
252
253   private static Problem localize(RestOpSession opSession, Problem problem) {
254      var strategy = opSession.getBeanStore().getBean(ProblemLocalizationStrategy.class).orElse(ProblemLocalizationStrategy.IDENTITY);
255      var locale = opSession.getRequest().getLocale();
256      var result = strategy.localize(problem, locale);
257      return result == null ? problem : result;
258   }
259
260   private static boolean acceptsProblemJson(String accept) {
261      if (isEmpty(accept))
262         return true;
263      return accept.contains(PROBLEM_JSON) || accept.contains("*/*");
264   }
265}