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 — serialized directly. Honors the client {@code Accept} header. 043 * <li>A {@link ProblemException} — unwrapped via {@link ProblemException#getProblem()}. Honors the 044 * client {@code Accept} header. 045 * <li>A {@link BasicHttpException} — 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} — 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 — 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)}) — 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) — 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 "no opinion" 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 — 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}