1   /*
2    * Copyright 2017 LINE Corporation
3    *
4    * LINE Corporation licenses this file to you under the Apache License,
5    * version 2.0 (the "License"); you may not use this file except in compliance
6    * with the License. You may obtain a copy of the License at:
7    *
8    *   https://www.apache.org/licenses/LICENSE-2.0
9    *
10   * Unless required by applicable law or agreed to in writing, software
11   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
12   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
13   * License for the specific language governing permissions and limitations
14   * under the License.
15   */
16  
17  package com.linecorp.centraldogma.server.internal.api;
18  
19  import static com.google.common.base.MoreObjects.firstNonNull;
20  import static com.google.common.base.Preconditions.checkArgument;
21  import static com.google.common.base.Strings.nullToEmpty;
22  import static java.util.Objects.requireNonNull;
23  
24  import java.util.Locale;
25  import java.util.concurrent.CompletionStage;
26  import java.util.function.BiFunction;
27  import java.util.function.Function;
28  import java.util.function.Supplier;
29  
30  import javax.annotation.Nullable;
31  
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  
35  import com.fasterxml.jackson.core.JsonProcessingException;
36  import com.fasterxml.jackson.databind.JsonNode;
37  import com.fasterxml.jackson.databind.node.JsonNodeFactory;
38  import com.fasterxml.jackson.databind.node.ObjectNode;
39  import com.google.common.collect.ImmutableList;
40  import com.google.common.collect.ImmutableMap;
41  
42  import com.linecorp.armeria.common.Flags;
43  import com.linecorp.armeria.common.HttpResponse;
44  import com.linecorp.armeria.common.HttpStatus;
45  import com.linecorp.armeria.common.MediaType;
46  import com.linecorp.armeria.common.logging.LogLevel;
47  import com.linecorp.armeria.common.util.Exceptions;
48  import com.linecorp.armeria.server.HttpResponseException;
49  import com.linecorp.armeria.server.ServiceRequestContext;
50  import com.linecorp.centraldogma.common.ShuttingDownException;
51  import com.linecorp.centraldogma.internal.Jackson;
52  import com.linecorp.centraldogma.server.metadata.User;
53  
54  import io.netty.util.AttributeKey;
55  
56  /**
57   * A utility class which provides common functions for HTTP API.
58   */
59  //TODO(minwoox) change this class to package-local when the admin API is integrated with HTTP API
60  public final class HttpApiUtil {
61  
62      private static final AttributeKey<Boolean> VERBOSE_RESPONSES =
63              AttributeKey.valueOf(HttpApiUtil.class, "VERBOSE_RESPONSES");
64  
65      private static final Logger logger = LoggerFactory.getLogger(HttpApiUtil.class);
66      private static final String ERROR_MESSAGE_FORMAT = "{} Returning a {} response: {}";
67  
68      static final JsonNode unremoveRequest = Jackson.valueToTree(
69              ImmutableList.of(
70                      ImmutableMap.of("op", "replace",
71                                      "path", "/status",
72                                      "value", "active")));
73  
74      /**
75       * Throws a newly created {@link HttpResponseException} with the specified {@link HttpStatus} and
76       * {@code message}.
77       */
78      public static <T> T throwResponse(ServiceRequestContext ctx, HttpStatus status, String message) {
79          throw HttpResponseException.of(newResponse(ctx, status, message));
80      }
81  
82      /**
83       * Throws a newly created {@link HttpResponseException} with the specified {@link HttpStatus} and
84       * the formatted message.
85       */
86      public static <T> T throwResponse(ServiceRequestContext ctx, HttpStatus status, String format,
87                                        Object... args) {
88          throw HttpResponseException.of(newResponse(ctx, status, format, args));
89      }
90  
91      /**
92       * Throws a newly created {@link HttpResponseException} with the specified {@link HttpStatus},
93       * {@code cause} and {@code message}.
94       */
95      public static <T> T throwResponse(ServiceRequestContext ctx, HttpStatus status, Throwable cause,
96                                        String message) {
97          throw HttpResponseException.of(newResponse(ctx, status, cause, message));
98      }
99  
100     /**
101      * Throws a newly created {@link HttpResponseException} with the specified {@link HttpStatus},
102      * {@code cause} and the formatted message.
103      */
104     public static <T> T throwResponse(ServiceRequestContext ctx, HttpStatus status, Throwable cause,
105                                       String format, Object... args) {
106         throw HttpResponseException.of(newResponse(ctx, status, cause, format, args));
107     }
108 
109     /**
110      * Returns a newly created {@link HttpResponse} with the specified {@link HttpStatus} and the formatted
111      * message.
112      */
113     public static HttpResponse newResponse(ServiceRequestContext ctx, HttpStatus status,
114                                            String format, Object... args) {
115         requireNonNull(ctx, "ctx");
116         requireNonNull(status, "status");
117         requireNonNull(format, "format");
118         requireNonNull(args, "args");
119         return newResponse(ctx, status, String.format(Locale.ENGLISH, format, args));
120     }
121 
122     /**
123      * Returns a newly created {@link HttpResponse} with the specified {@link HttpStatus} and {@code message}.
124      */
125     public static HttpResponse newResponse(ServiceRequestContext ctx, HttpStatus status, String message) {
126         requireNonNull(ctx, "ctx");
127         requireNonNull(status, "status");
128         requireNonNull(message, "message");
129         return newResponse0(ctx, status, null, message);
130     }
131 
132     /**
133      * Returns a newly created {@link HttpResponse} with the specified {@link HttpStatus} and {@code cause}.
134      */
135     public static HttpResponse newResponse(ServiceRequestContext ctx, HttpStatus status, Throwable cause) {
136         requireNonNull(ctx, "ctx");
137         requireNonNull(status, "status");
138         requireNonNull(cause, "cause");
139         return newResponse0(ctx, status, cause, null);
140     }
141 
142     /**
143      * Returns a newly created {@link HttpResponse} with the specified {@link HttpStatus}, {@code cause} and
144      * the formatted message.
145      */
146     public static HttpResponse newResponse(ServiceRequestContext ctx, HttpStatus status, Throwable cause,
147                                            String format, Object... args) {
148         requireNonNull(ctx, "ctx");
149         requireNonNull(status, "status");
150         requireNonNull(cause, "cause");
151         requireNonNull(format, "format");
152         requireNonNull(args, "args");
153 
154         return newResponse(ctx, status, cause, String.format(Locale.ENGLISH, format, args));
155     }
156 
157     /**
158      * Returns a newly created {@link HttpResponse} with the specified {@link HttpStatus}, {@code cause} and
159      * {@code message}.
160      */
161     public static HttpResponse newResponse(ServiceRequestContext ctx, HttpStatus status,
162                                            Throwable cause, String message) {
163         requireNonNull(ctx, "ctx");
164         requireNonNull(status, "status");
165         requireNonNull(cause, "cause");
166         requireNonNull(message, "message");
167 
168         return newResponse0(ctx, status, cause, message);
169     }
170 
171     private static HttpResponse newResponse0(ServiceRequestContext ctx, HttpStatus status,
172                                              @Nullable Throwable cause, @Nullable String message) {
173         checkArgument(!status.isContentAlwaysEmpty(),
174                       "status: %s (expected: a status with non-empty content)", status);
175 
176         final ObjectNode node = JsonNodeFactory.instance.objectNode();
177         if (cause != null) {
178             cause = Exceptions.peel(cause);
179             node.put("exception", cause.getClass().getName());
180             if (message == null) {
181                 message = cause.getMessage();
182             }
183         }
184 
185         final String m = nullToEmpty(message);
186         node.put("message", m);
187 
188         if (cause != null && isVerboseResponse(ctx)) {
189             node.put("detail", Exceptions.traceText(cause));
190         }
191 
192         final LogLevel logLevel;
193         switch (status.codeClass()) {
194             case SERVER_ERROR:
195                 if (cause != null) {
196                     if (!(Exceptions.isStreamCancelling(cause) ||
197                           cause instanceof ShuttingDownException)) {
198                         logLevel = LogLevel.WARN;
199                     } else {
200                         logLevel = null;
201                     }
202                 } else {
203                     logLevel = LogLevel.WARN;
204                 }
205                 break;
206             case CLIENT_ERROR:
207                 logLevel = LogLevel.DEBUG;
208                 break;
209             case UNKNOWN:
210                 logLevel = LogLevel.WARN;
211                 break;
212             default:
213                 logLevel = null;
214         }
215 
216         // TODO(trustin): Use LogLevel.OFF instead of null and logLevel.log()
217         //                once we add LogLevel.OFF and LogLevel.log() with more args.
218         if (logLevel != null) {
219             if (logLevel == LogLevel.WARN) {
220                 if (cause != null) {
221                     logger.warn(ERROR_MESSAGE_FORMAT, ctx, status, m, cause);
222                 } else {
223                     logger.warn(ERROR_MESSAGE_FORMAT, ctx, status, m);
224                 }
225             } else {
226                 if (cause != null) {
227                     logger.debug(ERROR_MESSAGE_FORMAT, ctx, status, m, cause);
228                 } else {
229                     logger.debug(ERROR_MESSAGE_FORMAT, ctx, status, m);
230                 }
231             }
232         }
233 
234         // TODO(hyangtack) Need to introduce a new field such as 'stackTrace' in order to return
235         //                 the stack trace of the cause to the trusted client.
236         try {
237             return HttpResponse.of(status, MediaType.JSON_UTF_8, Jackson.writeValueAsBytes(node));
238         } catch (JsonProcessingException e) {
239             // should not reach here
240             throw new Error(e);
241         }
242     }
243 
244     /**
245      * Ensures the specified {@link JsonNode} equals to {@link HttpApiUtil#unremoveRequest}.
246      *
247      * @throws IllegalArgumentException if the {@code node} does not equal to
248      *                                  {@link HttpApiUtil#unremoveRequest}.
249      */
250     static void checkUnremoveArgument(JsonNode node) {
251         checkArgument(unremoveRequest.equals(node),
252                       "Unsupported JSON patch: " + node +
253                       " (expected: " + unremoveRequest + ')');
254     }
255 
256     /**
257      * Ensures the specified {@code status} is valid.
258      *
259      * @throws IllegalArgumentException if the {@code status} is invalid.
260      */
261     static void checkStatusArgument(String status) {
262         checkArgument("removed".equalsIgnoreCase(status),
263                       "invalid status: " + status + " (expected: removed)");
264     }
265 
266     /**
267      * Throws the specified {@link Throwable} if it is not {@code null}. This is a special form to be used
268      * for {@link CompletionStage#handle(BiFunction)}.
269      */
270     @SuppressWarnings("unused")
271     static Void throwUnsafelyIfNonNull(@Nullable Object unused,
272                                        @Nullable Throwable cause) {
273         throwUnsafelyIfNonNull(cause);
274         return null;
275     }
276 
277     /**
278      * Throws the specified {@link Throwable} if it is not {@code null}.
279      */
280     public static void throwUnsafelyIfNonNull(@Nullable Throwable cause) {
281         if (cause != null) {
282             Exceptions.throwUnsafely(cause);
283         }
284     }
285 
286     /**
287      * Returns a {@link BiFunction} for a {@link CompletionStage#handle(BiFunction)} which returns an object
288      * if the specified {@link Throwable} is {@code null}. Otherwise, it throws the {@code cause}.
289      */
290     @SuppressWarnings("unchecked")
291     static <T, U> BiFunction<? super T, Throwable, ? extends U> returnOrThrow(Supplier<? super U> supplier) {
292         return (unused, cause) -> {
293             throwUnsafelyIfNonNull(cause);
294             return (U) supplier.get();
295         };
296     }
297 
298     /**
299      * Returns a {@link BiFunction} for a {@link CompletionStage#handle(BiFunction)} which returns an object
300      * applied by the specified {@code function} if the specified {@link Throwable} is {@code null}.
301      * Otherwise, it throws the {@code cause}.
302      */
303     static <T, U> BiFunction<? super T, Throwable, ? extends U> returnOrThrow(
304             Function<? super T, ? extends U> function) {
305         return (result, cause) -> {
306             throwUnsafelyIfNonNull(cause);
307             return function.apply(result);
308         };
309     }
310 
311     public static void setVerboseResponses(ServiceRequestContext ctx, User user) {
312         ctx.setAttr(VERBOSE_RESPONSES, Flags.verboseResponses() || user.isSystemAdmin());
313     }
314 
315     private static boolean isVerboseResponse(ServiceRequestContext ctx) {
316         final Boolean verboseResponses = ctx.attr(VERBOSE_RESPONSES);
317         return firstNonNull(verboseResponses, false);
318     }
319 
320     private HttpApiUtil() {}
321 }