001/*
002 * Copyright 2022-2025 Revetware LLC.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 * http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 */
016
017package com.soklet;
018
019import com.soklet.annotation.DELETE;
020import com.soklet.annotation.GET;
021import com.soklet.annotation.HEAD;
022import com.soklet.annotation.OPTIONS;
023import com.soklet.annotation.PATCH;
024import com.soklet.annotation.POST;
025import com.soklet.annotation.PUT;
026import com.soklet.annotation.ServerSentEventSource;
027
028import javax.annotation.concurrent.NotThreadSafe;
029import javax.annotation.processing.AbstractProcessor;
030import javax.annotation.processing.Filer;
031import javax.annotation.processing.Messager;
032import javax.annotation.processing.ProcessingEnvironment;
033import javax.annotation.processing.RoundEnvironment;
034import javax.lang.model.SourceVersion;
035import javax.lang.model.element.AnnotationMirror;
036import javax.lang.model.element.AnnotationValue;
037import javax.lang.model.element.Element;
038import javax.lang.model.element.ElementKind;
039import javax.lang.model.element.ExecutableElement;
040import javax.lang.model.element.Modifier;
041import javax.lang.model.element.TypeElement;
042import javax.lang.model.element.VariableElement;
043import javax.lang.model.type.TypeMirror;
044import javax.lang.model.util.Elements;
045import javax.lang.model.util.Types;
046import javax.tools.Diagnostic;
047import javax.tools.FileObject;
048import javax.tools.StandardLocation;
049import java.io.BufferedReader;
050import java.io.IOException;
051import java.io.InputStreamReader;
052import java.io.UncheckedIOException;
053import java.io.Writer;
054import java.lang.annotation.Annotation;
055import java.lang.annotation.Repeatable;
056import java.nio.charset.StandardCharsets;
057import java.util.ArrayList;
058import java.util.Arrays;
059import java.util.Base64;
060import java.util.Collections;
061import java.util.Comparator;
062import java.util.LinkedHashMap;
063import java.util.LinkedHashSet;
064import java.util.List;
065import java.util.Map;
066import java.util.Set;
067import java.util.stream.Collectors;
068
069/**
070 * Soklet's standard Annotation Processor which is used to generate lookup tables of <em>Resource Method</em> definitions at compile time as well as prevent usage errors that are detectable by static analysis.
071 * <p>
072 * This Annotation Processor ensures <em>Resource Methods</em> annotated with {@link ServerSentEventSource} are declared as returning an instance of {@link HandshakeResult}.
073 * <p>
074 * Your build system should ensure this Annotation Processor is available at compile time. Follow the instructions below to make your application conformant:
075 * <p>
076 * Using {@code javac} directly:
077 * <pre>javac -parameters -processor com.soklet.SokletProcessor ...[rest of javac command elided]</pre>
078 * Using <a href="https://maven.apache.org" target="_blank">Maven</a>:
079 * <pre>{@code <plugin>
080 *     <groupId>org.apache.maven.plugins</groupId>
081 *     <artifactId>maven-compiler-plugin</artifactId>
082 *     <version>...</version>
083 *     <configuration>
084 *         <release>...</release>
085 *         <compilerArgs>
086 *             <!-- Rest of args elided -->
087 *             <arg>-parameters</arg>
088 *             <arg>-processor</arg>
089 *             <arg>com.soklet.SokletProcessor</arg>
090 *         </compilerArgs>
091 *     </configuration>
092 * </plugin>}</pre>
093 * Using <a href="https://gradle.org" target="_blank">Gradle</a>:
094 * <pre>{@code def sokletVersion = "2.0.0" // (use your actual version)
095 *
096 * dependencies {
097 *   // Soklet used by your code at compile/run time
098 *   implementation "com.soklet:soklet:${sokletVersion}"
099 *
100 *   // Same artifact also provides the annotation processor
101 *   annotationProcessor "com.soklet:soklet:${sokletVersion}"
102 *
103 *   // If tests also need processing (optional)
104 *   testAnnotationProcessor "com.soklet:soklet:${sokletVersion}"
105 * }}</pre>
106 *
107 * @author <a href="https://www.revetkn.com">Mark Allen</a>
108 */
109@NotThreadSafe
110public final class SokletProcessor extends AbstractProcessor {
111        private Types types;
112        private Elements elements;
113        private Messager messager;
114        private Filer filer;
115
116        // Cached mirrors resolved in init()
117        private TypeMirror handshakeResultType; // com.soklet.HandshakeResult
118        private TypeElement pathParameterElement; // com.soklet.annotation.PathParameter
119
120        @Override
121        public synchronized void init(ProcessingEnvironment processingEnv) {
122                super.init(processingEnv);
123                this.types = processingEnv.getTypeUtils();
124                this.elements = processingEnv.getElementUtils();
125                this.messager = processingEnv.getMessager();
126                this.filer = processingEnv.getFiler();
127
128                TypeElement hr = elements.getTypeElement("com.soklet.HandshakeResult");
129                this.handshakeResultType = (hr == null ? null : hr.asType());
130                this.pathParameterElement = elements.getTypeElement("com.soklet.annotation.PathParameter");
131        }
132
133        @Override
134        public Set<String> getSupportedAnnotationTypes() {
135                Set<String> out = new LinkedHashSet<>();
136                for (Class<? extends Annotation> c : HTTP_AND_SSE_ANNOTATIONS) {
137                        out.add(c.getCanonicalName());
138                        Class<? extends Annotation> container = findRepeatableContainer(c);
139                        if (container != null) out.add(container.getCanonicalName());
140                }
141                return out;
142        }
143
144        @Override
145        public SourceVersion getSupportedSourceVersion() {return SourceVersion.latestSupported();}
146
147        private final List<ResourceMethodDeclaration> collected = new ArrayList<>();
148        private final Set<String> touchedTopLevelBinaries = new LinkedHashSet<>();
149
150        @Override
151        public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
152                for (Element root : roundEnv.getRootElements()) {
153                        if (root instanceof TypeElement te) {
154                                String bin = elements.getBinaryName(te).toString();
155                                touchedTopLevelBinaries.add(bin);
156                        }
157                }
158
159                // SSE-specific return type check
160                enforceSseReturnTypes(roundEnv);
161
162                // Collect + validate
163                collect(roundEnv, HttpMethod.GET, GET.class, false);
164                collect(roundEnv, HttpMethod.POST, POST.class, false);
165                collect(roundEnv, HttpMethod.PUT, PUT.class, false);
166                collect(roundEnv, HttpMethod.PATCH, PATCH.class, false);
167                collect(roundEnv, HttpMethod.DELETE, DELETE.class, false);
168                collect(roundEnv, HttpMethod.HEAD, HEAD.class, false);
169                collect(roundEnv, HttpMethod.OPTIONS, OPTIONS.class, false);
170                collect(roundEnv, HttpMethod.GET, ServerSentEventSource.class, true); // SSE as GET + flag
171
172                if (roundEnv.processingOver()) {
173                        mergeAndWriteIndex(collected, touchedTopLevelBinaries);
174                }
175                return false;
176        }
177
178        private static final List<Class<? extends Annotation>> HTTP_AND_SSE_ANNOTATIONS = List.of(
179                        GET.class, POST.class, PUT.class, PATCH.class, DELETE.class, HEAD.class, OPTIONS.class,
180                        ServerSentEventSource.class
181        );
182
183        /**
184         * Collects and validates each annotated method occurrence (repeatable-aware).
185         */
186        private void collect(RoundEnvironment roundEnv,
187                                                                                         HttpMethod httpMethod,
188                                                                                         Class<? extends Annotation> baseAnnotation,
189                                                                                         boolean serverSentEventSource) {
190
191                Set<Element> candidates = new LinkedHashSet<>();
192                TypeElement base = elements.getTypeElement(baseAnnotation.getCanonicalName());
193                if (base != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(base));
194                Class<? extends Annotation> containerAnn = findRepeatableContainer(baseAnnotation);
195                if (containerAnn != null) {
196                        TypeElement container = elements.getTypeElement(containerAnn.getCanonicalName());
197                        if (container != null) candidates.addAll(roundEnv.getElementsAnnotatedWith(container));
198                }
199
200                for (Element e : candidates) {
201                        if (e.getKind() != ElementKind.METHOD) {
202                                error(e, "Soklet: @%s can only be applied to methods.", baseAnnotation.getSimpleName());
203                                continue;
204                        }
205                        ExecutableElement method = (ExecutableElement) e;
206                        TypeElement owner = (TypeElement) method.getEnclosingElement();
207
208                        // -- Signature validations common to all HTTP annotations --
209                        // 1) no static
210                        if (method.getModifiers().contains(Modifier.STATIC)) {
211                                error(method, "Soklet: Resource Method must not be static");
212                                // keep validating path so we can show all issues in one compile, but we won't collect if any error
213                        }
214
215                        // Repeatable-aware: iterate each occurrence on the same method
216                        Annotation[] anns = method.getAnnotationsByType(cast(baseAnnotation));
217                        for (Annotation a : anns) {
218                                String rawPath = readAnnotationStringMember(a, "value");
219                                if (rawPath == null || rawPath.isBlank()) {
220                                        error(method, "Soklet: @%s must have a non-empty path value", baseAnnotation.getSimpleName());
221                                        continue;
222                                }
223
224                                // 2) path normalization + validation
225                                String path = normalizePath(rawPath);
226
227                                ValidationResult vr = validatePathTemplate(method, path);
228                                // If malformed, skip further param-name checks for this occurrence
229                                if (!vr.ok) {
230                                        continue;
231                                }
232
233                                // 3) @PathParameter bindings
234                                ParamBindings pb = readPathParameterBindings(method);
235                                // a) placeholders must be bound
236                                for (String placeholder : vr.placeholders) {            // <-- already normalized
237                                        if (!pb.paramNames.contains(placeholder)) {
238                                                // use the original token if you want to echo "{cssPath*}" in the message
239                                                String shown = vr.original.getOrDefault(placeholder, placeholder);
240                                                error(method, "Resource Method path parameter {" + shown + "} not bound to a @PathParameter argument");
241                                        }
242                                }
243
244                                // b) annotated params must exist in template
245                                for (String annotated : pb.paramNames) {                // annotated names are plain ("cssPath")
246                                        if (!vr.placeholders.contains(annotated)) {
247                                                error(method, "No placeholder {" + annotated + "} present in resource path declaration");
248                                        }
249                                }
250
251                                // Only collect if no errors were reported on this method occurrence
252                                if (!pb.hadError && vr.ok && !method.getModifiers().contains(Modifier.STATIC)) {
253                                        String className = elements.getBinaryName(owner).toString();
254                                        String methodName = method.getSimpleName().toString();
255                                        String[] paramTypes = method.getParameters().stream()
256                                                        .map(p -> jvmTypeName(p.asType()))
257                                                        .toArray(String[]::new);
258
259                                        collected.add(new ResourceMethodDeclaration(
260                                                        httpMethod, path, className, methodName, paramTypes, serverSentEventSource
261                                        ));
262                                }
263                        }
264                }
265        }
266
267        // --- Helpers for parameter annotations ------------------------------------
268
269        private static final class ParamBindings {
270                final Set<String> paramNames;
271                final boolean hadError;
272
273                ParamBindings(Set<String> names, boolean hadError) {
274                        this.paramNames = names;
275                        this.hadError = hadError;
276                }
277        }
278
279        private ParamBindings readPathParameterBindings(ExecutableElement method) {
280                boolean hadError = false;
281                Set<String> names = new LinkedHashSet<>();
282                if (pathParameterElement == null) return new ParamBindings(names, false);
283
284                for (VariableElement p : method.getParameters()) {
285                        for (AnnotationMirror am : p.getAnnotationMirrors()) {
286                                if (isAnnotationType(am, pathParameterElement)) {
287                                        // 1) try explicit annotation member
288                                        String name = readAnnotationStringMember(am, "name");
289                                        // 2) default to the parameter's source name if missing/blank
290                                        if (name == null || name.isBlank()) {
291                                                name = p.getSimpleName().toString();
292                                        }
293                                        if (name != null && !name.isBlank()) {
294                                                names.add(name);
295                                        }
296                                }
297                        }
298                }
299                return new ParamBindings(names, hadError);
300        }
301
302        private static boolean isAnnotationType(AnnotationMirror am, TypeElement type) {
303                return am.getAnnotationType().asElement().equals(type);
304        }
305
306        // Overload: read member from AnnotationMirror (compile-safe, no reflection)
307        private static String readAnnotationStringMember(AnnotationMirror am, String member) {
308                for (Map.Entry<? extends ExecutableElement, ? extends AnnotationValue> e : am.getElementValues().entrySet()) {
309                        if (e.getKey().getSimpleName().contentEquals(member)) {
310                                Object v = e.getValue().getValue();
311                                return (v == null) ? null : v.toString();
312                        }
313                }
314                return null;
315        }
316
317        // Existing reflective reader (used for repeatable occurrences)
318        private static String readAnnotationStringMember(Annotation a, String memberName) {
319                try {
320                        Object v = a.annotationType().getMethod(memberName).invoke(a);
321                        return (v == null) ? null : v.toString();
322                } catch (ReflectiveOperationException ex) {
323                        return null;
324                }
325        }
326
327        // --- Path parsing/validation ----------------------------------------------
328
329        private static final class ValidationResult {
330                final boolean ok;
331                final Set<String> placeholders;       // normalized names (no trailing '*')
332                final Map<String, String> original;   // normalized -> original token (e.g., "cssPath" -> "cssPath*")
333
334                ValidationResult(boolean ok, Set<String> placeholders, Map<String, String> original) {
335                        this.ok = ok;
336                        this.placeholders = placeholders;
337                        this.original = original;
338                }
339        }
340
341        /**
342         * Validates braces and duplicate placeholders (treating {name*} as a greedy/varargs
343         * placeholder whose logical name is "name"). Duplicate detection is done on the
344         * normalized name (without the trailing '*').
345         * <p>
346         * Emits diagnostics on the element if malformed or duplicate.
347         */
348        private ValidationResult validatePathTemplate(Element reportOn, String path) {
349                if (path == null || path.isEmpty()) {
350                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
351                }
352
353                Set<String> names = new LinkedHashSet<>();
354                Map<String, String> originalTokens = new LinkedHashMap<>();
355
356                int i = 0;
357                while (i < path.length()) {
358                        char c = path.charAt(i);
359                        if (c == '{') {
360                                int close = path.indexOf('}', i + 1);
361                                if (close < 0) {
362                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
363                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
364                                }
365                                String token = path.substring(i + 1, close);   // e.g., "id", "cssPath*"
366                                if (token.isEmpty()) {
367                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
368                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
369                                }
370
371                                String normalized = normalizePlaceholder(token); // strip trailing '*' if present
372                                if (normalized.isEmpty()) {
373                                        error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
374                                        return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
375                                }
376
377                                // Duplicate on normalized name
378                                if (!names.add(normalized)) {
379                                        error(reportOn, "Soklet: Duplicate @PathParameter name: " + normalized);
380                                }
381                                // keep first-seen original token for any potential messaging
382                                originalTokens.putIfAbsent(normalized, token);
383
384                                i = close + 1;
385                        } else if (c == '}') {
386                                error(reportOn, "Soklet: Malformed resource path declaration (unbalanced braces)");
387                                return new ValidationResult(false, Collections.emptySet(), Collections.emptyMap());
388                        } else {
389                                i++;
390                        }
391                }
392                return new ValidationResult(true, names, originalTokens);
393        }
394
395        /**
396         * Accepts vararg placeholders like "cssPath*" and returns the logical name "cssPath".
397         */
398        private static String normalizePlaceholder(String token) {
399                if (token.endsWith("*")) return token.substring(0, token.length() - 1);
400                return token;
401        }
402
403        // --- Existing utilities ----------------------------------------------------
404
405        @SuppressWarnings({"rawtypes", "unchecked"})
406        private static Class<? extends Annotation> cast(Class<? extends Annotation> c) {return (Class) c;}
407
408        private static String normalizePath(String p) {
409                if (p == null || p.isEmpty()) return "/";
410                if (p.charAt(0) != '/') return "/" + p;
411                return p;
412        }
413
414        private static Class<? extends Annotation> findRepeatableContainer(Class<? extends Annotation> base) {
415                Repeatable repeatable = base.getAnnotation(Repeatable.class);
416                return (repeatable == null) ? null : repeatable.value();
417        }
418
419        private String jvmTypeName(TypeMirror t) {
420                switch (t.getKind()) {
421                        case BOOLEAN:
422                                return "boolean";
423                        case BYTE:
424                                return "byte";
425                        case SHORT:
426                                return "short";
427                        case CHAR:
428                                return "char";
429                        case INT:
430                                return "int";
431                        case LONG:
432                                return "long";
433                        case FLOAT:
434                                return "float";
435                        case DOUBLE:
436                                return "double";
437                        case VOID:
438                                return "void";
439                        case ARRAY:
440                                return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType());
441                        case DECLARED:
442                        default:
443                                TypeMirror erasure = processingEnv.getTypeUtils().erasure(t);
444                                Element el = processingEnv.getTypeUtils().asElement(erasure);
445                                if (el instanceof TypeElement te) {
446                                        return processingEnv.getElementUtils().getBinaryName(te).toString();
447                                }
448                                return erasure.toString();
449                }
450        }
451
452        private String jvmTypeDescriptor(TypeMirror t) {
453                switch (t.getKind()) {
454                        case BOOLEAN:
455                                return "Z";
456                        case BYTE:
457                                return "B";
458                        case SHORT:
459                                return "S";
460                        case CHAR:
461                                return "C";
462                        case INT:
463                                return "I";
464                        case LONG:
465                                return "J";
466                        case FLOAT:
467                                return "F";
468                        case DOUBLE:
469                                return "D";
470                        case ARRAY:
471                                return "[" + jvmTypeDescriptor(((javax.lang.model.type.ArrayType) t).getComponentType());
472                        case DECLARED:
473                        default:
474                                TypeMirror erasure = processingEnv.getTypeUtils().erasure(t);
475                                Element el = processingEnv.getTypeUtils().asElement(erasure);
476                                if (el instanceof TypeElement te) {
477                                        String bin = processingEnv.getElementUtils().getBinaryName(te).toString();
478                                        return "L" + bin + ";";
479                                }
480                                return "Ljava/lang/Object;";
481                }
482        }
483
484        // ---- SSE return-type validation ------------------------------------------
485
486        private void enforceSseReturnTypes(RoundEnvironment roundEnv) {
487                if (handshakeResultType == null) return;
488                TypeElement sseAnn = elements.getTypeElement(ServerSentEventSource.class.getCanonicalName());
489                if (sseAnn == null) return;
490
491                for (Element e : roundEnv.getElementsAnnotatedWith(sseAnn)) {
492                        if (e.getKind() != ElementKind.METHOD) {
493                                error(e, "@%s can only be applied to methods.", ServerSentEventSource.class.getSimpleName());
494                                continue;
495                        }
496                        ExecutableElement method = (ExecutableElement) e;
497                        TypeMirror returnType = method.getReturnType();
498                        boolean assignable = types.isAssignable(returnType, handshakeResultType);
499                        if (!assignable) {
500                                error(e,
501                                                "Soklet: Resource Methods annotated with @%s must specify a return type of %s (found: %s).",
502                                                ServerSentEventSource.class.getSimpleName(), "HandshakeResult", prettyType(returnType));
503                        }
504                }
505        }
506
507        private static String prettyType(TypeMirror t) {return (t == null ? "null" : t.toString());}
508
509        // ---- Index read/merge/write ----------------------------------------------
510
511        static String RESOURCE_METHOD_LOOKUP_TABLE_PATH = "META-INF/soklet/resource-method-lookup-table";
512
513        private void mergeAndWriteIndex(List<ResourceMethodDeclaration> newlyCollected,
514                                                                                                                                        Set<String> touchedTopLevelBinaries) {
515                Map<String, ResourceMethodDeclaration> merged = readExistingIndex();
516
517                if (!touchedTopLevelBinaries.isEmpty()) {
518                        merged.values().removeIf(r -> {
519                                String ownerBin = r.className();
520                                for (String top : touchedTopLevelBinaries) {
521                                        if (ownerBin.equals(top) || ownerBin.startsWith(top + "$")) return true;
522                                }
523                                return false;
524                        });
525                }
526
527                merged.values().removeIf(r -> {
528                        String canonical = binaryToCanonical(r.className());
529                        TypeElement te = elements.getTypeElement(canonical);
530                        return te == null;
531                });
532
533                for (ResourceMethodDeclaration r : dedupeAndOrder(newlyCollected)) {
534                        merged.put(generateKey(r), r);
535                }
536
537                List<ResourceMethodDeclaration> toWrite = new ArrayList<>(merged.values());
538                toWrite.sort(Comparator
539                                .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name())
540                                .thenComparing(ResourceMethodDeclaration::path)
541                                .thenComparing(ResourceMethodDeclaration::className)
542                                .thenComparing(ResourceMethodDeclaration::methodName));
543
544                writeRoutesIndexResource(toWrite);
545        }
546
547        private static String binaryToCanonical(String binary) {return binary.replace('$', '.');}
548
549        private Map<String, ResourceMethodDeclaration> readExistingIndex() {
550                Map<String, ResourceMethodDeclaration> out = new LinkedHashMap<>();
551                BufferedReader reader = null;
552                try {
553                        FileObject fo = filer.getResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH);
554                        reader = new BufferedReader(new InputStreamReader(fo.openInputStream(), StandardCharsets.UTF_8));
555                        String line;
556                        while ((line = reader.readLine()) != null) {
557                                line = line.trim();
558                                if (line.isEmpty()) continue;
559                                ResourceMethodDeclaration r = parseIndexLine(line);
560                                if (r != null) out.put(generateKey(r), r);
561                        }
562                } catch (IOException ignored) {
563                } finally {
564                        if (reader != null) try {
565                                reader.close();
566                        } catch (IOException ignored) {
567                        }
568                }
569                return out;
570        }
571
572        private ResourceMethodDeclaration parseIndexLine(String line) {
573                try {
574                        String[] parts = line.split("\\|", -1);
575                        if (parts.length < 6) return null;
576
577                        HttpMethod httpMethod = HttpMethod.valueOf(parts[0]);
578                        Base64.Decoder dec = Base64.getDecoder();
579
580                        String path = new String(dec.decode(parts[1]), StandardCharsets.UTF_8);
581                        String className = new String(dec.decode(parts[2]), StandardCharsets.UTF_8);
582                        String methodName = new String(dec.decode(parts[3]), StandardCharsets.UTF_8);
583                        String paramsJoined = new String(dec.decode(parts[4]), StandardCharsets.UTF_8);
584                        boolean sse = Boolean.parseBoolean(parts[5]);
585
586                        String[] paramTypes;
587                        if (paramsJoined.isEmpty()) {
588                                paramTypes = new String[0];
589                        } else {
590                                List<String> tmp = Arrays.stream(paramsJoined.split(";"))
591                                                .filter(s -> !s.isEmpty())
592                                                .collect(Collectors.toList());
593                                paramTypes = tmp.toArray(String[]::new);
594                        }
595                        return new ResourceMethodDeclaration(httpMethod, path, className, methodName, paramTypes, sse);
596                } catch (Throwable t) {
597                        return null;
598                }
599        }
600
601        private void writeRoutesIndexResource(List<ResourceMethodDeclaration> routes) {
602                try {
603                        FileObject fo = filer.createResource(StandardLocation.CLASS_OUTPUT, "", RESOURCE_METHOD_LOOKUP_TABLE_PATH);
604                        try (Writer w = fo.openWriter()) {
605                                Base64.Encoder b64 = Base64.getEncoder();
606                                for (ResourceMethodDeclaration r : routes) {
607                                        String params = String.join(";", r.parameterTypes());
608                                        String line = String.join("|",
609                                                        r.httpMethod().name(),
610                                                        b64encode(b64, r.path()),
611                                                        b64encode(b64, r.className()),
612                                                        b64encode(b64, r.methodName()),
613                                                        b64encode(b64, params),
614                                                        Boolean.toString(r.serverSentEventSource())
615                                        );
616                                        w.write(line);
617                                        w.write('\n');
618                                }
619                        }
620                } catch (IOException e) {
621                        throw new UncheckedIOException("Failed to write " + RESOURCE_METHOD_LOOKUP_TABLE_PATH, e);
622                }
623        }
624
625        private static String b64encode(Base64.Encoder enc, String s) {
626                byte[] bytes = (s == null ? new byte[0] : s.getBytes(StandardCharsets.UTF_8));
627                return enc.encodeToString(bytes);
628        }
629
630        // ---- Messaging ------------------------------------------------------------
631
632        private void error(Element e, String fmt, Object... args) {
633                messager.printMessage(Diagnostic.Kind.ERROR, String.format(fmt, args), e);
634        }
635
636        private static String generateKey(ResourceMethodDeclaration r) {
637                return r.httpMethod().name() + "|" + r.path() + "|" + r.className() + "|" +
638                                r.methodName() + "|" + String.join(";", r.parameterTypes()) + "|" +
639                                r.serverSentEventSource();
640        }
641
642        private static List<ResourceMethodDeclaration> dedupeAndOrder(List<ResourceMethodDeclaration> in) {
643                Map<String, ResourceMethodDeclaration> byKey = new LinkedHashMap<>();
644                for (ResourceMethodDeclaration r : in) byKey.putIfAbsent(generateKey(r), r);
645                List<ResourceMethodDeclaration> out = new ArrayList<>(byKey.values());
646                out.sort(Comparator
647                                .comparing((ResourceMethodDeclaration r) -> r.httpMethod().name())
648                                .thenComparing(ResourceMethodDeclaration::path)
649                                .thenComparing(ResourceMethodDeclaration::className)
650                                .thenComparing(ResourceMethodDeclaration::methodName));
651                return out;
652        }
653}