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.converter;
18  
19  import static com.google.common.base.Ascii.toLowerCase;
20  import static com.google.common.base.Strings.isNullOrEmpty;
21  
22  import java.lang.reflect.ParameterizedType;
23  import java.util.HashMap;
24  import java.util.Iterator;
25  import java.util.Map;
26  import java.util.concurrent.TimeUnit;
27  
28  import javax.annotation.Nullable;
29  
30  import com.google.common.annotations.VisibleForTesting;
31  import com.google.common.base.MoreObjects;
32  import com.google.common.base.Splitter;
33  
34  import com.linecorp.armeria.common.AggregatedHttpRequest;
35  import com.linecorp.armeria.common.HttpHeaderNames;
36  import com.linecorp.armeria.server.ServiceRequestContext;
37  import com.linecorp.armeria.server.annotation.RequestConverterFunction;
38  import com.linecorp.centraldogma.common.Revision;
39  
40  /**
41   * A request converter that converts to {@link WatchRequest} when the request contains
42   * {@link HttpHeaderNames#IF_NONE_MATCH}.
43   */
44  public final class WatchRequestConverter implements RequestConverterFunction {
45  
46      private static final long DEFAULT_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(120);
47  
48      private static final Splitter preferenceSplitter = Splitter.on(',').omitEmptyStrings().trimResults();
49  
50      private static final Splitter tokenSplitter = Splitter.on('=').omitEmptyStrings().trimResults();
51  
52      private static final String NOTIFY_ENTRY_NOT_FOUND = "notify-entry-not-found";
53  
54      /**
55       * Converts the specified {@code request} to a {@link WatchRequest} when the request has
56       * {@link HttpHeaderNames#IF_NONE_MATCH}. {@code null} otherwise.
57       */
58      @Override
59      @Nullable
60      public WatchRequest convertRequest(
61              ServiceRequestContext ctx, AggregatedHttpRequest request, Class<?> expectedResultType,
62              @Nullable ParameterizedType expectedParameterizedResultType) throws Exception {
63  
64          final String ifNoneMatch = request.headers().get(HttpHeaderNames.IF_NONE_MATCH);
65          if (isNullOrEmpty(ifNoneMatch)) {
66              return null;
67          }
68  
69          final Revision lastKnownRevision = new Revision(extractRevision(ifNoneMatch));
70          final String prefer = request.headers().get(HttpHeaderNames.PREFER);
71          final long timeoutMillis;
72          final boolean notifyEntryNotFound;
73          if (!isNullOrEmpty(prefer)) {
74              final Map<String, String> tokens = extract(prefer);
75              timeoutMillis = timeoutMillis(tokens, prefer);
76              notifyEntryNotFound = notifyEntryNotFound(tokens);
77          } else {
78              timeoutMillis = DEFAULT_TIMEOUT_MILLIS;
79              notifyEntryNotFound = false;
80          }
81  
82          return new WatchRequest(lastKnownRevision, timeoutMillis, notifyEntryNotFound);
83      }
84  
85      @VisibleForTesting
86      String extractRevision(String ifNoneMatch) {
87          final int length = ifNoneMatch.length();
88  
89          // Three below cases are valid. See https://github.com/line/centraldogma/issues/415
90          // - <revision> (for backward compatibility)
91          // - "<revision>"
92          // - W/"<revision>"
93          if (length > 2 && ifNoneMatch.charAt(0) == '"' &&
94              ifNoneMatch.charAt(length - 1) == '"') {
95              return ifNoneMatch.substring(1, length - 1);
96          }
97  
98          if (length > 4 && ifNoneMatch.startsWith("W/\"") &&
99              ifNoneMatch.charAt(length - 1) == '"') {
100             return ifNoneMatch.substring(3, length - 1);
101         }
102 
103         return ifNoneMatch;
104     }
105 
106     // TODO(minwoox) Use https://github.com/line/armeria/issues/1835
107     private static Map<String, String> extract(String preferHeader) {
108         final Iterable<String> preferences = preferenceSplitter.split(preferHeader);
109         final HashMap<String, String> tokens = new HashMap<>();
110         for (String preference : preferences) {
111             final Iterable<String> split = tokenSplitter.split(preference);
112             final Iterator<String> iterator = split.iterator();
113             if (iterator.hasNext()) {
114                 final String token = iterator.next();
115                 if (iterator.hasNext()) {
116                     final String value = iterator.next();
117                     tokens.put(toLowerCase(token), value);
118                 }
119             }
120         }
121         return tokens;
122     }
123 
124     private static long timeoutMillis(Map<String, String> tokens, String preferHeader) {
125         final String wait = tokens.get("wait");
126         if (wait == null) {
127             return rejectPreferHeader(preferHeader, "wait=seconds");
128         }
129 
130         final long timeoutSeconds;
131         try {
132             timeoutSeconds = Long.parseLong(wait);
133         } catch (NumberFormatException e) {
134             return rejectPreferHeader(preferHeader, "wait=seconds");
135         }
136 
137         if (timeoutSeconds <= 0) {
138             return rejectPreferHeader(preferHeader, "seconds > 0");
139         }
140         return TimeUnit.SECONDS.toMillis(timeoutSeconds);
141     }
142 
143     private static long rejectPreferHeader(String preferHeader, String expected) {
144         throw new IllegalArgumentException("invalid prefer header: " + preferHeader +
145                                            " (expected: " + expected + ')');
146     }
147 
148     private static boolean notifyEntryNotFound(Map<String, String> tokens) {
149         final String notifyEntryNotFound = tokens.get(NOTIFY_ENTRY_NOT_FOUND);
150         if ("true".equalsIgnoreCase(notifyEntryNotFound)) {
151             return true;
152         }
153         // Default value is false.
154         return false;
155     }
156 
157     public static class WatchRequest {
158         private final Revision lastKnownRevision;
159         private final long timeoutMillis;
160         private final boolean notifyEntryNotFound;
161 
162         WatchRequest(Revision lastKnownRevision, long timeoutMillis, boolean notifyEntryNotFound) {
163             this.lastKnownRevision = lastKnownRevision;
164             this.timeoutMillis = timeoutMillis;
165             this.notifyEntryNotFound = notifyEntryNotFound;
166         }
167 
168         public Revision lastKnownRevision() {
169             return lastKnownRevision;
170         }
171 
172         public long timeoutMillis() {
173             return timeoutMillis;
174         }
175 
176         public boolean notifyEntryNotFound() {
177             return notifyEntryNotFound;
178         }
179 
180         @Override
181         public String toString() {
182             return MoreObjects.toStringHelper(this)
183                               .add("lastKnownRevision", lastKnownRevision)
184                               .add("timeoutMillis", timeoutMillis)
185                               .toString();
186         }
187     }
188 }