diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..cc66270 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,18 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{xml,yml,yaml,json}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false + +[Makefile] +indent_style = tab diff --git a/.gitignore b/.gitignore index d681b6a..513a8e8 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ buildNumber.properties .project # JDT-specific (Eclipse Java Development Tools) .classpath + +.idea/ +*.iml +*.iws +*.ipr +*.log +*.tmp +*.bak +*.swp +*.swo +*.DS_Store +.air/ diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..98c7155 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,36 @@ + + + 4.0.0 + + com.euonia + parent + ${revision} + + + core + euonia core module + + Core module of the Euonia framework + + + + org.junit.jupiter + junit-jupiter + 5.12.2 + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + + diff --git a/core/src/main/java/com/euonia/annotation/Required.java b/core/src/main/java/com/euonia/annotation/Required.java new file mode 100644 index 0000000..05cbbf7 --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Required.java @@ -0,0 +1,15 @@ +package com.euonia.annotation; + +import java.lang.annotation.*; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Validation(validator = RequiredValidator.class) +public @interface Required { + + boolean allowEmpty() default false; + + String message() default ""; + + Class annotation() default Required.class; +} diff --git a/core/src/main/java/com/euonia/annotation/RequiredValidator.java b/core/src/main/java/com/euonia/annotation/RequiredValidator.java new file mode 100644 index 0000000..f2bed71 --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/RequiredValidator.java @@ -0,0 +1,18 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; + +public class RequiredValidator implements Validator { + @Override + public Duet validate(Required annotation, Object value) { + Duet result; + if (value == null) { + result = new Duet<>(false, annotation.message().isEmpty() ? "Value is required" : annotation.message()); + } else if (!annotation.allowEmpty() && value instanceof String string && string.isEmpty()) { + result = new Duet<>(false, annotation.message().isEmpty() ? "Value must not be empty" : annotation.message()); + } else { + result = new Duet<>(true, ""); + } + return result; + } +} diff --git a/core/src/main/java/com/euonia/annotation/Validation.java b/core/src/main/java/com/euonia/annotation/Validation.java new file mode 100644 index 0000000..9eeb5ae --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Validation.java @@ -0,0 +1,12 @@ +package com.euonia.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.ANNOTATION_TYPE) +public @interface Validation { + Class> validator(); +} diff --git a/core/src/main/java/com/euonia/annotation/Validator.java b/core/src/main/java/com/euonia/annotation/Validator.java new file mode 100644 index 0000000..956eb6b --- /dev/null +++ b/core/src/main/java/com/euonia/annotation/Validator.java @@ -0,0 +1,9 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; + +import java.lang.annotation.Annotation; + +public interface Validator { + Duet validate(A annotation, Object value); +} diff --git a/core/src/main/java/com/euonia/core/ObjectId.java b/core/src/main/java/com/euonia/core/ObjectId.java new file mode 100644 index 0000000..e656df4 --- /dev/null +++ b/core/src/main/java/com/euonia/core/ObjectId.java @@ -0,0 +1,149 @@ +package com.euonia.core; + +import java.util.UUID; + +/** + * ObjectId is a class that represents a unique identifier for an object. + * It can be generated using different algorithms such as Snowflake, GUID, + * Random, and ULID. + * The value of the ObjectId can be of type long, String, UUID, or Integer. + * The class provides methods to generate ObjectIds using different algorithms + * and overrides the hashCode, equals, and toString methods for proper + * functionality. + * The ObjectId class is immutable, meaning that once an ObjectId is created, + * its value cannot be changed. + * This ensures that the uniqueness of the identifier is maintained throughout + * its lifecycle. + */ +public final class ObjectId { + private final Object value; + + /** + * Gets the value of the ObjectId. + * + * @return the value of the ObjectId + */ + public Object getValue() { + return value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(long value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(String value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(UUID value) { + this.value = value; + } + + /** + * Constructs an ObjectId with the specified value. + * + * @param value the value of the ObjectId + */ + public ObjectId(Integer value) { + this.value = value; + } + + /** + * Generates a new ObjectId using the Snowflake algorithm. + * + * @return a new ObjectId + */ + public static ObjectId snowflake() { + return new ObjectId(SnowflakeId.getInstance().nextId()); + } + + /** + * Generates a new ObjectId using the GUID algorithm. + * + * @return a new ObjectId + */ + public static ObjectId guid() { + return new ObjectId(UUID.randomUUID()); + } + + /** + * Generates a new ObjectId using the Random algorithm. + * + * @return a new ObjectId + */ + public static ObjectId random() { + return new ObjectId(UUID.randomUUID()); + } + + /** + * Generates a new ObjectId using the ULID algorithm. + * + * @return a new ObjectId + */ + public static ObjectId ulid() { + return new ObjectId(ULID.generate()); + } + + public T getValue(Class type) { + if (type.isInstance(value)) { + return type.cast(value); + } + return null; + } + + @Override + public int hashCode() { + return value.hashCode(); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null || getClass() != obj.getClass()) + return false; + ObjectId objectId = (ObjectId) obj; + return value.equals(objectId.value); + } + + @Override + public String toString() { + + if (value instanceof UUID target) { + return target.toString(); + } + + if (value instanceof Integer target) { + return target.toString(); + } + + if (value instanceof Long target) { + return target.toString(); + } + + if (value instanceof String target) { + return target; + } + + if (value instanceof ULID target) { + return target.toString(); + } + + return value.toString(); + } +} diff --git a/core/src/main/java/com/euonia/core/Pair.java b/core/src/main/java/com/euonia/core/Pair.java new file mode 100644 index 0000000..858199f --- /dev/null +++ b/core/src/main/java/com/euonia/core/Pair.java @@ -0,0 +1,18 @@ +package com.euonia.core; + +/** + * A generic class that represents a pair of key and value. The key must be comparable to ensure that pairs can be sorted based on their keys. + * + * @param key the key of the pair + * @param value the value associated with the key + * @param the type of the key + * @param the type of the value + */ +public record Pair, V>(K key, V value) { + public static , V> Pair of(K key, V value) { + if (key == null) { + throw new IllegalArgumentException("key is null"); + } + return new Pair<>(key, value); + } +} diff --git a/core/src/main/java/com/euonia/core/PriorityQueue.java b/core/src/main/java/com/euonia/core/PriorityQueue.java new file mode 100644 index 0000000..a4471e4 --- /dev/null +++ b/core/src/main/java/com/euonia/core/PriorityQueue.java @@ -0,0 +1,53 @@ +package com.euonia.core; + +import java.util.Comparator; + +public class PriorityQueue> { + private final java.util.PriorityQueue> queue; + + public PriorityQueue() { + this.queue = new java.util.PriorityQueue<>(Comparator.comparing(Pair::key)); + } + + /** + * Inserts the specified element into this priority queue with the given priority. + * + * @param value the element to be added + * @param priority the priority of the element + */ + public void add(E value, K priority) { + queue.add(new Pair<>(priority, value)); + } + + /** + * Retrieves and removes the head of this queue, or returns null if this queue is empty. + * + * @return the value of the highest priority element, or null if the queue is empty + */ + public E poll() { + Pair pair = queue.poll(); + return pair != null ? pair.value() : null; + } + + /** + * Retrieves, but does not remove, the head of this queue. This method differs from peek only in that it throws an exception if this queue is empty. + * + * @return the value of the highest priority element, or null if the queue is empty + */ + public E peek() { + Pair pair = queue.peek(); + return pair != null ? pair.value() : null; + } + + public void clear() { + queue.clear(); + } + + public int size() { + return queue.size(); + } + + public boolean isEmpty() { + return queue.isEmpty(); + } +} diff --git a/core/src/main/java/com/euonia/core/PriorityValueFinder.java b/core/src/main/java/com/euonia/core/PriorityValueFinder.java new file mode 100644 index 0000000..aa17699 --- /dev/null +++ b/core/src/main/java/com/euonia/core/PriorityValueFinder.java @@ -0,0 +1,67 @@ +package com.euonia.core; + +import java.util.function.Consumer; +import java.util.function.Predicate; +import java.util.function.Supplier; + +/** + * Utility class for finding a value in a priority queue based on a filter predicate. + * This class provides a method to search through a priority queue and return the first value that matches the given filter. + * If no matching value is found, it returns a specified default value. + */ +public final class PriorityValueFinder { + + /** + * Finds the first value in the priority queue that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param values the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(PriorityQueue, Integer> values, Predicate filter, T defaultValue) { + + if (values == null) { + throw new IllegalArgumentException("Values queue cannot be null or empty"); + } + + if (filter == null) { + throw new IllegalArgumentException("Filter queue cannot be null"); + } + + if (values.isEmpty()) { + return defaultValue; + } + + while (!values.isEmpty()) { + T value = values.poll().get(); + if (filter.test(value)) { + return value; + } + } + return defaultValue; + } + + /** + * Finds the first value in the priority queue provided by the consumer that matches the given filter. + * If no matching value is found, returns the default value. + * + * @param queueConsumer the consumer that provides the priority queue to search + * @param filter the filter predicate to apply to each value + * @param defaultValue the value to return if no matching value is found + * @param the type of values in the priority queue + * @return the first matching value or the default value if none is found + */ + public static T find(Consumer, Integer>> queueConsumer, Predicate filter, T defaultValue) { + if (queueConsumer == null) { + throw new IllegalArgumentException("Queue consumer cannot be null"); + } + + var queue = new PriorityQueue, Integer>(); + queueConsumer.accept(queue); + return find(queue, filter, defaultValue); + } +} + diff --git a/core/src/main/java/com/euonia/core/ShortUniqueId.java b/core/src/main/java/com/euonia/core/ShortUniqueId.java new file mode 100644 index 0000000..0e31dd7 --- /dev/null +++ b/core/src/main/java/com/euonia/core/ShortUniqueId.java @@ -0,0 +1,521 @@ +package com.euonia.core; + +import java.util.*; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * ShortUniqueId is a utility class for generating short, unique string IDs from integers or long integers. + * It provides methods to encode and decode integers, long integers, and hexadecimal strings into short unique IDs. + * The class allows customization of the alphabet, separators, guards, and salt used in the encoding process to ensure uniqueness and security. + * The implementation is based on the Hashids algorithm, which is designed to create short, non-sequential, and URL-friendly IDs. + */ +@SuppressWarnings("unused") +public final class ShortUniqueId { + private static final String DEFAULT_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890"; + private static final String DEFAULT_SEPS = "cfhistuCFHISTU"; + private static final int MIN_ALPHABET_LENGTH = 16; + private static final int MAX_STACK_ALLOC_SIZE = 512; + private static final double SEP_DIV = 3.5; + private static final double GUARD_DIV = 12.0; + + private final char[] alphabet; + private final char[] seps; + private final char[] guards; + private final char[] salt; + private final int minHashLength; + private final int minBufferSize; + + private static final Pattern HEX_VALIDATOR = Pattern.compile("^[0-9a-fA-F]+$"); + private static final Pattern HEX_SPLITTER = Pattern.compile(".{1,12}"); + + private static final ShortUniqueId DEFAULT = new ShortUniqueId(); + + public static ShortUniqueId getDefault() { + return DEFAULT; + } + + public ShortUniqueId() { + this("", 0, DEFAULT_ALPHABET, DEFAULT_SEPS); + } + + public ShortUniqueId(String salt, int minHashLength, String alphabet, String seps) { + if (salt == null) throw new IllegalArgumentException("salt"); + if (minHashLength < 0) throw new IllegalArgumentException("minHashLength must be >= 0"); + if (alphabet == null || alphabet.isBlank()) throw new IllegalArgumentException("alphabet"); + if (seps == null || seps.isBlank()) throw new IllegalArgumentException("seps"); + + this.salt = salt.trim().toCharArray(); + this.minHashLength = minHashLength; + + // unique alphabet chars + StringBuilder alphaBuilder = new StringBuilder(); + for (char c : alphabet.toCharArray()) if (alphaBuilder.indexOf(String.valueOf(c)) == -1) alphaBuilder.append(c); + char[] alpha = toCharArray(alphaBuilder.toString()); + + char[] sepsArr = toCharArray(seps); + + this.minBufferSize = Math.max(20, minHashLength); + + if (alpha.length < MIN_ALPHABET_LENGTH) + throw new IllegalArgumentException("Alphabet must contain at least " + MIN_ALPHABET_LENGTH + " unique characters."); + + // seps must be from alphabet + if (sepsArr.length > 0) { + List tmp = new ArrayList<>(); + for (char c : sepsArr) { + for (char a : alpha) + if (a == c) { + tmp.add(c); + break; + } + } + sepsArr = toCharArray(listToString(tmp)); + } + + // remove seps from alphabet + if (sepsArr.length > 0) { + List newAlpha = new ArrayList<>(); + for (char a : alpha) { + boolean ok = true; + for (char s : sepsArr) + if (a == s) { + ok = false; + break; + } + if (ok) newAlpha.add(a); + } + alpha = listToCharArray(newAlpha); + } + + if (alpha.length < (MIN_ALPHABET_LENGTH - 6)) + throw new IllegalArgumentException("Alphabet must contain at least " + (MIN_ALPHABET_LENGTH - 6) + " unique characters not present in seps."); + + // consistent shuffle seps with salt + consistentShuffle(sepsArr, this.salt); + + if (sepsArr.length == 0 || ((float) alpha.length / sepsArr.length) > SEP_DIV) { + int sepsLength = (int) Math.ceil((float) alpha.length / SEP_DIV); + if (sepsLength == 1) sepsLength = 2; + if (sepsLength > sepsArr.length) { + int diff = sepsLength - sepsArr.length; + sepsArr = append(sepsArr, alpha, diff); + alpha = subArray(alpha, diff); + } else { + sepsArr = subArray(sepsArr, 0, sepsLength); + } + } + + consistentShuffle(alpha, this.salt); + + int guardCount = (int) Math.ceil(alpha.length / GUARD_DIV); + char[] guardsArr; + if (alpha.length < 3) { + guardsArr = subArray(sepsArr, 0, guardCount); + sepsArr = subArray(sepsArr, guardCount); + } else { + guardsArr = subArray(alpha, 0, guardCount); + alpha = subArray(alpha, guardCount); + } + + this.alphabet = alpha; + this.seps = sepsArr; + this.guards = guardsArr; + } + + // Public encode/decode methods + + /** + * Encode a single integer into a short unique string ID. + * + * @param number the integer to encode + * @return a short unique string ID representing the input integer + */ + public String encode(int number) { + return encode(new long[]{number}); + } + + /** + * Encode one or more integers into a short unique string ID. + * + * @param numbers the integers to encode + * @return a short unique string ID representing the input integers + */ + public String encode(int... numbers) { + long[] arr = new long[numbers.length]; + for (int i = 0; i < numbers.length; i++) arr[i] = numbers[i]; + return encode(arr); + } + + /** + * Encode a collection of integers into a short unique string ID. + * + * @param numbers the collection of integers to encode + * @return a short unique string ID representing the input integers + */ + public String encode(Collection numbers) { + long[] arr = numbers.stream().mapToLong(Integer::longValue).toArray(); + return encode(arr); + } + + /** + * Encode a single long integer into a short unique string ID. + * + * @param number the long integer to encode + * @return a short unique string ID representing the input long integer + */ + public String encode(long number) { + return encode(new long[]{number}); + } + + /** + * Encode one or more long integers into a short unique string ID. + * + * @param numbers the long integers to encode + * @return a short unique string ID representing the input long integers + */ + public String encode(long... numbers) { + if (numbers == null || numbers.length == 0) return ""; + for (long n : numbers) if (n < 0) return ""; + + long numbersHashInt = 0; + for (int i = 0; i < numbers.length; i++) numbersHashInt += numbers[i] % (i + 100); + + StringBuilder sb = new StringBuilder(); + + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char lottery = alphabetCopy[(int) (numbersHashInt % alphabetCopy.length)]; + sb.append(lottery); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + for (int i = 0; i < numbers.length; i++) { + long number = numbers[i]; + + if (length > 0) System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + + consistentShuffle(alphabetCopy, buffer); + char[] hashBuffer = new char[this.minBufferSize]; + int hashLength = buildReversedHash(number, alphabetCopy, hashBuffer); + + // append reversed + for (int j = hashLength - 1; j >= 0; j--) sb.append(hashBuffer[j]); + + if (i + 1 < numbers.length) { + number %= hashBuffer[Math.max(0, hashLength - 1)] + i; + int sepsIndex = (int) (number % this.seps.length); + sb.append(this.seps[sepsIndex]); + } + } + + if (sb.length() < this.minHashLength) { + int guardIndex = (int) ((numbersHashInt + sb.charAt(0)) % this.guards.length); + char guard = this.guards[guardIndex]; + sb.insert(0, guard); + if (sb.length() < this.minHashLength) { + guardIndex = (int) ((numbersHashInt + sb.charAt(2)) % this.guards.length); + guard = this.guards[guardIndex]; + sb.append(guard); + } + } + + int halfLength = this.alphabet.length / 2; + while (sb.length() < this.minHashLength) { + char[] tmp = Arrays.copyOf(this.alphabet, this.alphabet.length); + consistentShuffle(tmp, buffer); + sb.insert(0, new String(tmp, halfLength, tmp.length - halfLength)); + sb.append(new String(tmp, 0, halfLength)); + + int excess = sb.length() - this.minHashLength; + if (excess > 0) { + sb.delete(0, excess / 2); + if (sb.length() > this.minHashLength) sb.delete(this.minHashLength, sb.length()); + } + } + + return sb.toString(); + } + + /** + * Decode a short unique string ID back into the original integer(s). + * + * @param hash the short unique string ID to decode + * @return an array of integers representing the original values + */ + public int[] decode(String hash) { + long[] longs = decodeLong(hash); + int[] res = new int[longs.length]; + for (int i = 0; i < longs.length; i++) { + res[i] = (int) longs[i]; + } + return res; + } + + /** + * Decode a short unique string ID back into the original long integer(s). + * + * @param hash the short unique string ID to decode + * @return an array of long integers representing the original values + */ + public long[] decodeLong(String hash) { + if (hash == null || hash.isBlank()) { + return new long[0]; + } + return numbersFrom(hash); + } + + /** + * Encode a hexadecimal string into a short unique string ID. The input hex string is split into 12-character chunks, each chunk is prefixed with '1' to ensure it can be parsed as a valid long integer, and then encoded using the standard encoding method. + * + * @param hex the hexadecimal string to encode + * @return a short unique string ID representing the input hexadecimal string, or an empty string if the input is invalid + */ + public String encodeHex(String hex) { + if (hex == null || hex.isBlank() || !HEX_VALIDATOR.matcher(hex).matches()) return ""; + Matcher m = HEX_SPLITTER.matcher(hex); + List numbers = new ArrayList<>(); + while (m.find()) { + String match = m.group(); + String concat = "1" + match; + long number = Long.parseLong(concat, 16); + numbers.add(number); + } + long[] arr = numbers.stream().mapToLong(Long::longValue).toArray(); + return encode(arr); + } + + /** + * Decode a short unique string ID back into the original hexadecimal string. The decoded long integers are converted back to hexadecimal strings, concatenated together, and returned as the final result. + * + * @param hash the short unique string ID to decode + * @return the original hexadecimal string represented by the input ID, or an empty string if the input is invalid + */ + public String decodeHex(String hash) { + long[] numbers = decodeLong(hash); + StringBuilder sb = new StringBuilder(); + for (long number : numbers) { + String s = Long.toHexString(number).toUpperCase(Locale.ROOT); + for (int i = 1; i < s.length(); i++) sb.append(s.charAt(i)); + } + return sb.toString(); + } + + // Internal helpers + private int buildReversedHash(long input, char[] alphabet, char[] hashBuffer) { + int length = 0; + do { + int idx = (int) (input % alphabet.length); + hashBuffer[length++] = alphabet[idx]; + input /= alphabet.length; + } while (input > 0 && length < hashBuffer.length); + return length; + } + + private long unhash(char[] input, char[] alphabet) { + long number = 0; + for (char c : input) { + int pos = indexOf(alphabet, c); + number = (number * this.alphabet.length) + pos; + } + return number; + } + + private long getNumberFrom(String hash) { + if (hash == null || hash.isBlank()) return -1; + // split guards + SplitResult guarded = split(hash, this.guards); + int unguardedIndex = (guarded.count == 3 || guarded.count == 2) ? 1 : 0; + Range r = guarded.ranges[unguardedIndex]; + String hashBreakdown = hash.substring(r.start, r.start + r.length); + char lottery = hashBreakdown.charAt(0); + if (lottery == '\0') return -1; + + String hashBuffer = hashBreakdown.substring(1); + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + if (Math.min(this.salt.length, alphabetCopy.length - 1) >= 0) { + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + } + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + if (length > 0) { + System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + } + consistentShuffle(alphabetCopy, buffer); + long result = unhash(hashBuffer.toCharArray(), alphabetCopy); + + // regenerate and compare + String rehash = encode(result); + if (hash.equals(rehash)) return result; + return -1; + } + + private long[] numbersFrom(String hash) { + if (hash == null || hash.isBlank()) return new long[0]; + + SplitResult guarded = split(hash, this.guards); + if (guarded.count == 0) return new long[0]; + int unguardedIndex = (guarded.count == 3 || guarded.count == 2) ? 1 : 0; + Range rg = guarded.ranges[unguardedIndex]; + String hashBreakdown = hash.substring(rg.start, rg.start + rg.length); + + if (hashBreakdown.isEmpty()) return new long[0]; + + char lottery = hashBreakdown.charAt(0); + if (lottery == '\0') return new long[0]; + + String hashBuffer = hashBreakdown.substring(1); + SplitResult split = split(hashBuffer, this.seps); + int parts = split.count; + long[] result = new long[parts]; + + char[] alphabetCopy = Arrays.copyOf(this.alphabet, this.alphabet.length); + + char[] buffer = new char[alphabetCopy.length + this.salt.length + 1]; + buffer[0] = lottery; + if (Math.min(this.salt.length, alphabetCopy.length - 1) >= 0) { + System.arraycopy(this.salt, 0, buffer, 1, Math.min(this.salt.length, alphabetCopy.length - 1)); + } + + int startIndex = 1 + this.salt.length; + int length = alphabetCopy.length - startIndex; + + for (int index = 0; index < parts; index++) { + Range rr = split.ranges[index]; + String subHash = hashBuffer.substring(rr.start, rr.start + rr.length); + if (length > 0) { + System.arraycopy(alphabetCopy, 0, buffer, startIndex, length); + } + consistentShuffle(alphabetCopy, buffer); + result[index] = unhash(subHash.toCharArray(), alphabetCopy); + } + + // validate + String rehash = encode(result); + if (hash.equals(rehash)) return result; + return new long[0]; + } + + // util functions + private static char[] toCharArray(String s) { + return s == null ? new char[0] : s.toCharArray(); + } + + private static String listToString(List list) { + StringBuilder sb = new StringBuilder(); + for (char c : list) sb.append(c); + return sb.toString(); + } + + private static char[] listToCharArray(List list) { + char[] a = new char[list.size()]; + for (int i = 0; i < list.size(); i++) a[i] = list.get(i); + return a; + } + + private static int indexOf(char[] arr, char c) { + for (int i = 0; i < arr.length; i++) if (arr[i] == c) return i; + return -1; + } + + private static void consistentShuffle(char[] alphabet, char[] salt) { + if (salt == null || salt.length == 0) return; + for (int i = alphabet.length - 1, v = 0, p = 0; i > 0; i--, v++) { + v %= salt.length; + int saltNum = salt[v]; + p += saltNum; + int j = (saltNum + v + p) % i; + // swap + char tmp = alphabet[i]; + alphabet[i] = alphabet[j]; + alphabet[j] = tmp; + } + } + + private static char[] subArray(char[] array, int index) { + return subArray(array, index, array.length - index); + } + + private static char[] subArray(char[] array, int index, int length) { + if (length == 0) return new char[0]; + char[] sub = new char[length]; + System.arraycopy(array, index, sub, 0, length); + return sub; + } + + private static char[] append(char[] array, char[] appendArray, int length) { + if (length == 0) { + return array; + } + int newLength = array.length + length; + if (newLength == 0) { + return new char[0]; + } + char[] newArr = new char[newLength]; + System.arraycopy(array, 0, newArr, 0, array.length); + System.arraycopy(appendArray, 0, newArr, array.length, length); + return newArr; + } + + // Splitting by separators: return ranges of substrings between any of separators chars + private static class Range { + int start; + int length; + + Range(int s, int l) { + start = s; + length = l; + } + } + + private static class SplitResult { + int count; + Range[] ranges; + + SplitResult(int c, Range[] r) { + count = c; + ranges = r; + } + } + + private SplitResult split(String line, char[] separators) { + int count = 0; + int indexStart = 0; + int nextSeparatorIndex = 0; + Range[] ranges = new Range[line.length()]; + boolean isLastLoop = false; + while (!isLastLoop) { + indexStart += nextSeparatorIndex; + nextSeparatorIndex = indexOfAny(line, indexStart, separators); + if (nextSeparatorIndex == 0) { + indexStart++; + nextSeparatorIndex = indexOfAny(line, indexStart, separators); + } + isLastLoop = nextSeparatorIndex == -1; + if (isLastLoop) nextSeparatorIndex = line.length() - indexStart; + String slice = line.substring(indexStart, indexStart + nextSeparatorIndex); + if (slice.isEmpty()) continue; + ranges[count++] = new Range(indexStart, nextSeparatorIndex); + } + return new SplitResult(count, Arrays.copyOf(ranges, count)); + } + + private int indexOfAny(String s, int from, char[] any) { + for (int i = from; i < s.length(); i++) { + char c = s.charAt(i); + for (char sep : any) if (c == sep) return i - from; + } + return -1; + } +} + diff --git a/core/src/main/java/com/euonia/core/Singleton.java b/core/src/main/java/com/euonia/core/Singleton.java new file mode 100644 index 0000000..a558bea --- /dev/null +++ b/core/src/main/java/com/euonia/core/Singleton.java @@ -0,0 +1,31 @@ +package com.euonia.core; + +import java.lang.reflect.InvocationTargetException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; + +public final class Singleton { + private static final ConcurrentHashMap, Object> instances = new ConcurrentHashMap<>(); + + private Singleton() { + // private constructor to prevent instantiation + } + + @SuppressWarnings("unchecked") + public static T getInstance(Class clazz) { + return (T) instances.computeIfAbsent(clazz, Singleton::createInstance); + } + + private static T createInstance(Class clazz) { + try { + return clazz.getDeclaredConstructor().newInstance(); + } catch (IllegalAccessException | IllegalArgumentException | InstantiationException | NoSuchMethodException + | InvocationTargetException e) { + throw new RuntimeException("Failed to create instance of " + clazz.getName(), e); + } + } + + public static T get(Class clazz, Supplier supplier) { + return (T) instances.computeIfAbsent(clazz, k -> supplier.get()); + } +} diff --git a/core/src/main/java/com/euonia/core/SnowflakeId.java b/core/src/main/java/com/euonia/core/SnowflakeId.java new file mode 100644 index 0000000..04a4fcb --- /dev/null +++ b/core/src/main/java/com/euonia/core/SnowflakeId.java @@ -0,0 +1,90 @@ +package com.euonia.core; + +/** + * SnowflakeId is a distributed unique ID generator inspired by Twitter's Snowflake algorithm. + * It generates 64-bit unique IDs based on the current timestamp, a worker ID, a datacenter ID, and a sequence number. + * The generated IDs are sortable by time and can be generated in a distributed environment without coordination between nodes. + * The structure of the generated ID is as follows: + * - 41 bits for the timestamp (in milliseconds since a custom epoch) + * - 5 bits for the datacenter ID + * - 5 bits for the worker ID + * - 12 bits for the sequence number + * This implementation allows for up to 1024 unique IDs per millisecond per worker and supports up to 32 datacenters and 32 workers per datacenter. + * The custom epoch is set to January 1, 2021, which allows for a long lifespan of the ID generation without running out of bits for the timestamp. + */ +@SuppressWarnings("unused") +public final class SnowflakeId { + private static final long EPOCH = 1609459200000L; // 2021-01-01 00:00:00 UTC + private static final long WORKER_ID_BITS = 5L; + private static final long DATACENTER_ID_BITS = 5L; + private static final long SEQUENCE_BITS = 12L; + + private static final long MAX_WORKER_ID = ~(-1L << WORKER_ID_BITS); + private static final long MAX_DATACENTER_ID = ~(-1L << DATACENTER_ID_BITS); + private static final long MAX_SEQUENCE = ~(-1L << SEQUENCE_BITS); + + private static final long WORKER_ID_SHIFT = SEQUENCE_BITS; + private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS; + private static final long TIMESTAMP_LEFT_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS; + + private final long workerId; + private final long datacenterId; + private long sequence = 0L; + private long lastTimestamp = -1L; + + private SnowflakeId(long workerId, long datacenterId) { + if (workerId > MAX_WORKER_ID || workerId < 0) { + throw new IllegalArgumentException(String.format("Worker ID must be between 0 and %d", MAX_WORKER_ID)); + } + if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) { + throw new IllegalArgumentException(String.format("Datacenter ID must be between 0 and %d", MAX_DATACENTER_ID)); + } + this.workerId = workerId; + this.datacenterId = datacenterId; + } + + public static synchronized SnowflakeId getInstance(long workerId, long datacenterId) { + return new SnowflakeId(workerId, datacenterId); + } + + public static synchronized SnowflakeId getInstance() { + return new SnowflakeId(0, 0); + } + + public synchronized long nextId() { + long timestamp = timeGen(); + + if (timestamp < lastTimestamp) { + throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp)); + } + + if (lastTimestamp == timestamp) { + sequence = (sequence + 1) & MAX_SEQUENCE; + if (sequence == 0) { + timestamp = tilNextMillis(lastTimestamp); + } + } else { + sequence = 0L; + } + + lastTimestamp = timestamp; + + return ((timestamp - EPOCH) << TIMESTAMP_LEFT_SHIFT) + | (datacenterId << DATACENTER_ID_SHIFT) + | (workerId << WORKER_ID_SHIFT) + | sequence; + } + + private long tilNextMillis(long lastTimestamp) { + long timestamp = timeGen(); + while (timestamp <= lastTimestamp) { + timestamp = timeGen(); + } + return timestamp; + } + + private long timeGen() { + return System.currentTimeMillis(); + } +} + diff --git a/core/src/main/java/com/euonia/core/ULID.java b/core/src/main/java/com/euonia/core/ULID.java new file mode 100644 index 0000000..7a79ea1 --- /dev/null +++ b/core/src/main/java/com/euonia/core/ULID.java @@ -0,0 +1,96 @@ +package com.euonia.core; + +import java.security.SecureRandom; +import java.time.Instant; + +/** + * ULID (Universally Unique Lexicographically Sortable Identifier) generator. + *

+ * ULID is a 128-bit universally unique identifier that is lexicographically + * sortable and URL-safe. + *

+ */ +@SuppressWarnings("unused") +public final class ULID { + + /** + * ULID uses a specific 32-character encoding known as Crockford's Base32, + * which includes digits and upper-case letters but excludes letters like "I", + * "L", "O" + * to avoid confusion with digits. + */ + private static final String CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + + private ULID() { + // utility class, prevent instantiation + } + + /** + * Generates a new ULID string. + * + * @return a 32-character ULID string + */ + public static String generate() { + byte[] timestamp = getTimestamp(); + byte[] randomBytes = getRandomBytes(); + return encode(timestamp, randomBytes); + } + + /** + * Gets the current UTC timestamp as a 48-bit (6 bytes) big-endian value. + * + * @return 6 bytes representing the timestamp in milliseconds since Unix epoch + */ + private static byte[] getTimestamp() { + long timestamp = Instant.now().toEpochMilli(); + // Convert to 8 bytes in big-endian order + byte[] bytes = new byte[8]; + bytes[0] = (byte) (timestamp >> 56); + bytes[1] = (byte) (timestamp >> 48); + bytes[2] = (byte) (timestamp >> 40); + bytes[3] = (byte) (timestamp >> 32); + bytes[4] = (byte) (timestamp >> 24); + bytes[5] = (byte) (timestamp >> 16); + bytes[6] = (byte) (timestamp >> 8); + bytes[7] = (byte) timestamp; + // Extract the last 6 bytes (lower 48 bits) + byte[] result = new byte[6]; + System.arraycopy(bytes, 2, result, 0, 6); + return result; + } + + /** + * Generates 10 cryptographically secure random bytes (80 bits). + * + * @return 10 random bytes + */ + private static byte[] getRandomBytes() { + byte[] randomBytes = new byte[10]; + SECURE_RANDOM.nextBytes(randomBytes); + return randomBytes; + } + + /** + * Encodes the timestamp and random bytes into a Crockford's Base32 encoded ULID + * string. + * + * @param timestamp 6 bytes of timestamp data + * @param randomBytes 10 bytes of random data + * @return a Base32-encoded ULID string + */ + private static String encode(byte[] timestamp, byte[] randomBytes) { + byte[] ulidBytes = new byte[16]; + System.arraycopy(timestamp, 0, ulidBytes, 0, 6); + System.arraycopy(randomBytes, 0, ulidBytes, 6, 10); + + StringBuilder ulid = new StringBuilder(32); + for (byte b : ulidBytes) { + int value = b & 0xFF; + ulid.append(CROCKFORD_BASE32.charAt((value >> 3) & 0x1F)); + ulid.append(CROCKFORD_BASE32.charAt(value & 0x1F)); + } + return ulid.toString(); + } +} diff --git a/core/src/main/java/com/euonia/http/BadGatewayException.java b/core/src/main/java/com/euonia/http/BadGatewayException.java new file mode 100644 index 0000000..a0f3de2 --- /dev/null +++ b/core/src/main/java/com/euonia/http/BadGatewayException.java @@ -0,0 +1,29 @@ +package com.euonia.http; + +/** + * The BadGatewayException is thrown when an HTTP request results in a 502 Bad Gateway status code. + * This exception indicates that the server, while acting as a gateway or proxy, received an invalid response from the upstream server it accessed in attempting to fulfill the request. + * It is typically used in scenarios where the server is acting as a gateway or proxy and encounters an error while trying to communicate with the upstream server, such as when the upstream server is down, overloaded, or returns an invalid response. + * This exception provides constructors to create an exception with a specific message and an optional cause for the exception, allowing for more detailed error information to be included when the exception is thrown. + */ +public class BadGatewayException extends HttpStatusException { + + /** + * Creates a new BadGatewayException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public BadGatewayException(String message) { + super(502, message); + } + + /** + * Creates a new BadGatewayException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public BadGatewayException(String message, Throwable cause) { + super(502, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/BadRequestException.java b/core/src/main/java/com/euonia/http/BadRequestException.java new file mode 100644 index 0000000..bfb608c --- /dev/null +++ b/core/src/main/java/com/euonia/http/BadRequestException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The BadRequestException class represents an HTTP 400 Bad Request error. It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class BadRequestException extends HttpStatusException { + + /** + * Creates a new BadRequestException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public BadRequestException(String message) { + super(400, message); + } + + /** + * Creates a new BadRequestException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public BadRequestException(String message, Throwable cause) { + super(400, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ConflictException.java b/core/src/main/java/com/euonia/http/ConflictException.java new file mode 100644 index 0000000..810e8f1 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ConflictException.java @@ -0,0 +1,27 @@ +package com.euonia.http; + +/** + * The ConflictException class represents an HTTP 409 Conflict error. It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ConflictException extends HttpStatusException { + + /** + * Creates a new ConflictException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ConflictException(String message) { + super(409, message); + } + + /** + * Creates a new ConflictException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ConflictException(String message, Throwable cause) { + super(409, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/ForbiddenException.java b/core/src/main/java/com/euonia/http/ForbiddenException.java new file mode 100644 index 0000000..4641af8 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ForbiddenException.java @@ -0,0 +1,30 @@ +package com.euonia.http; + +/** + * The ForbiddenException class represents an HTTP 403 Forbidden error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ForbiddenException extends HttpStatusException { + + /** + * Creates a new ForbiddenException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ForbiddenException(String message) { + super(403, message); + } + + /** + * Creates a new ForbiddenException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ForbiddenException(String message, Throwable cause) { + super(403, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/GatewayTimeoutException.java b/core/src/main/java/com/euonia/http/GatewayTimeoutException.java new file mode 100644 index 0000000..5d382ab --- /dev/null +++ b/core/src/main/java/com/euonia/http/GatewayTimeoutException.java @@ -0,0 +1,30 @@ +package com.euonia.http; + +/** + * The GatewayTimeoutException class represents an HTTP 504 Gateway Timeout error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class GatewayTimeoutException extends HttpStatusException { + + /** + * Creates a new GatewayTimeoutException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public GatewayTimeoutException(String message) { + super(504, message); + } + + /** + * Creates a new GatewayTimeoutException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public GatewayTimeoutException(String message, Throwable cause) { + super(504, message, cause); + } + +} diff --git a/core/src/main/java/com/euonia/http/HttpStatusException.java b/core/src/main/java/com/euonia/http/HttpStatusException.java new file mode 100644 index 0000000..a91b2fe --- /dev/null +++ b/core/src/main/java/com/euonia/http/HttpStatusException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The HttpStatusException is thrown when an HTTP request results in an error status code. + * It contains the HTTP status code and a message describing the error. + * This exception can be used to represent various HTTP errors, such as 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 500 Internal Server Error, etc. + * It provides constructors to create an exception with a specific status code and message, as well as an optional cause for the exception. + */ +public class HttpStatusException extends RuntimeException { + private final int statusCode; + + public HttpStatusException(int statusCode, String message) { + super(message); + this.statusCode = statusCode; + } + + public HttpStatusException(int statusCode, String message, Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + +} diff --git a/core/src/main/java/com/euonia/http/InternalServerErrorException.java b/core/src/main/java/com/euonia/http/InternalServerErrorException.java new file mode 100644 index 0000000..55ae5c8 --- /dev/null +++ b/core/src/main/java/com/euonia/http/InternalServerErrorException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The InternalServerErrorException class represents an HTTP 500 Internal Server Error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class InternalServerErrorException extends HttpStatusException { + /** + * Creates a new InternalServerErrorException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public InternalServerErrorException(String message) { + super(500, message); + } + + /** + * Creates a new InternalServerErrorException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public InternalServerErrorException(String message, Throwable cause) { + super(500, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/MethodNotAllowedException.java b/core/src/main/java/com/euonia/http/MethodNotAllowedException.java new file mode 100644 index 0000000..c9fe84e --- /dev/null +++ b/core/src/main/java/com/euonia/http/MethodNotAllowedException.java @@ -0,0 +1,26 @@ +package com.euonia.http; + +/** + * The MethodNotAllowedException class represents an HTTP 405 Method Not Allowed error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class MethodNotAllowedException extends HttpStatusException { + /** + * Creates a new MethodNotAllowedException with the specified message. This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public MethodNotAllowedException(String message) { + super(405, message); + } + + /** + * Creates a new MethodNotAllowedException with the specified message and cause. This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public MethodNotAllowedException(String message, Throwable cause) { + super(405, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/RequestTimeoutException.java b/core/src/main/java/com/euonia/http/RequestTimeoutException.java new file mode 100644 index 0000000..d67643c --- /dev/null +++ b/core/src/main/java/com/euonia/http/RequestTimeoutException.java @@ -0,0 +1,27 @@ +package com.euonia.http; +/** + * The RequestTimeoutException class represents an HTTP 408 Request Timeout error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class RequestTimeoutException extends HttpStatusException { + /** + * Creates a new RequestTimeoutException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public RequestTimeoutException(String message) { + super(408, message); + } + + /** + * Creates a new RequestTimeoutException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public RequestTimeoutException(String message, Throwable cause) { + super(408, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ResourceNotFoundException.java b/core/src/main/java/com/euonia/http/ResourceNotFoundException.java new file mode 100644 index 0000000..dad5a5b --- /dev/null +++ b/core/src/main/java/com/euonia/http/ResourceNotFoundException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The ResourceNotFoundException class represents an HTTP 404 Not Found error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ResourceNotFoundException extends HttpStatusException { + /** + * Creates a new ResourceNotFoundException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ResourceNotFoundException(String message) { + super(404, message); + } + + /** + * Creates a new ResourceNotFoundException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ResourceNotFoundException(String message, Throwable cause) { + super(404, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java b/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java new file mode 100644 index 0000000..35c6160 --- /dev/null +++ b/core/src/main/java/com/euonia/http/ResponseHttpStatusCode.java @@ -0,0 +1,18 @@ +package com.euonia.http; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.RUNTIME) +public @interface ResponseHttpStatusCode { + /** + * The HTTP status code associated with the response. + * This value is used to indicate the status of the HTTP response, such as 400 for bad request, 404 for not found, etc. + * + * @return The HTTP status code for the response. + */ + int value(); +} diff --git a/core/src/main/java/com/euonia/http/ServiceUnavailableException.java b/core/src/main/java/com/euonia/http/ServiceUnavailableException.java new file mode 100644 index 0000000..24e11ad --- /dev/null +++ b/core/src/main/java/com/euonia/http/ServiceUnavailableException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The ServiceUnavailableException class represents an HTTP 503 Service Unavailable error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class ServiceUnavailableException extends HttpStatusException { + /** + * Creates a new ServiceUnavailableException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public ServiceUnavailableException(String message) { + super(503, message); + } + + /** + * Creates a new ServiceUnavailableException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public ServiceUnavailableException(String message, Throwable cause) { + super(503, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/TooManyRequestsException.java b/core/src/main/java/com/euonia/http/TooManyRequestsException.java new file mode 100644 index 0000000..e6d9e37 --- /dev/null +++ b/core/src/main/java/com/euonia/http/TooManyRequestsException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The TooManyRequestsException class represents an HTTP 429 Too Many Requests error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class TooManyRequestsException extends HttpStatusException { + /** + * Creates a new TooManyRequestsException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public TooManyRequestsException(String message) { + super(429, message); + } + + /** + * Creates a new TooManyRequestsException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public TooManyRequestsException(String message, Throwable cause) { + super(429, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/http/UpgradeRequiredException.java b/core/src/main/java/com/euonia/http/UpgradeRequiredException.java new file mode 100644 index 0000000..6b418dd --- /dev/null +++ b/core/src/main/java/com/euonia/http/UpgradeRequiredException.java @@ -0,0 +1,28 @@ +package com.euonia.http; + +/** + * The UpgradeRequiredException class represents an HTTP 426 Upgrade Required error. + * It extends the HttpStatusException class and provides constructors for creating instances of this exception with a specific message and an optional cause. + */ +public class UpgradeRequiredException extends HttpStatusException { + /** + * Creates a new UpgradeRequiredException with the specified message. + * This constructor allows for creating an exception with a specific message, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + */ + public UpgradeRequiredException(String message) { + super(426, message); + } + + /** + * Creates a new UpgradeRequiredException with the specified message and cause. + * This constructor allows for creating an exception with a specific message and cause, providing more detailed error information when the exception is thrown. + * + * @param message The detail message for the exception. + * @param cause The cause of the exception. + */ + public UpgradeRequiredException(String message, Throwable cause) { + super(426, message, cause); + } +} diff --git a/core/src/main/java/com/euonia/reflection/DisplayName.java b/core/src/main/java/com/euonia/reflection/DisplayName.java new file mode 100644 index 0000000..6864f82 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/DisplayName.java @@ -0,0 +1,16 @@ +package com.euonia.reflection; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a display name for a field, which can be used in UI or error messages instead of the actual field name. + * This is particularly useful for providing more user-friendly names for fields that may have technical or non-descriptive names in the code. + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.FIELD}) +public @interface DisplayName { + String value(); +} diff --git a/core/src/main/java/com/euonia/reflection/GenericType.java b/core/src/main/java/com/euonia/reflection/GenericType.java new file mode 100644 index 0000000..018a548 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/GenericType.java @@ -0,0 +1,62 @@ +package com.euonia.reflection; + +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; + +public abstract class GenericType { + private final Type type; + + private GenericType(Type type) { + this.type = type; + } + + protected GenericType() { + Class parameterizedTypeReferenceSubclass = findGenericTypeReferenceSubclass(getClass()); + Type type = parameterizedTypeReferenceSubclass.getGenericSuperclass(); + if (!(type instanceof ParameterizedType parameterizedType)) { + throw new IllegalArgumentException("Type must be a parameterized type"); + } + //Assert.isInstanceOf(ParameterizedType.class, type, "Type must be a parameterized type"); + Type[] actualTypeArguments = parameterizedType.getActualTypeArguments(); + if (actualTypeArguments.length != 1) { + throw new IllegalArgumentException("Wrong number of actual type arguments"); + } + //Assert.isTrue(actualTypeArguments.length == 1, "Number of type arguments must be 1"); + this.type = actualTypeArguments[0]; + } + + public Type getType() { + return this.type; + } + + public static GenericType forType(Type type) { + return new GenericType<>(type) { + }; + } + + @Override + public boolean equals(Object other) { + return (this == other || (other instanceof GenericType that && this.type.equals(that.type))); + } + + @Override + public int hashCode() { + return this.type.hashCode(); + } + + @Override + public String toString() { + return "GenericType<" + this.type + ">"; + } + + private static Class findGenericTypeReferenceSubclass(Class child) { + Class parent = child.getSuperclass(); + if (Object.class == parent) { + throw new IllegalStateException("Expected GenericType superclass"); + } else if (GenericType.class == parent) { + return child; + } else { + return findGenericTypeReferenceSubclass(parent); + } + } +} diff --git a/core/src/main/java/com/euonia/reflection/TypeHelper.java b/core/src/main/java/com/euonia/reflection/TypeHelper.java new file mode 100644 index 0000000..866f8f1 --- /dev/null +++ b/core/src/main/java/com/euonia/reflection/TypeHelper.java @@ -0,0 +1,463 @@ +package com.euonia.reflection; + +import java.lang.reflect.Array; +import java.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeParseException; +import java.time.temporal.Temporal; +import java.util.*; + +@SuppressWarnings("unused") +public final class TypeHelper { + private TypeHelper() { + } + + public static Object coerceValue(Class desiredType, Class valueType, Object value) { + if (desiredType == null) + throw new IllegalArgumentException("desiredType is null"); + + if (value == null) { + if (desiredType.isPrimitive()) { + return defaultPrimitiveValue(desiredType); + } + return null; + } + + if (valueType == null) + valueType = value.getClass(); + + if (desiredType.isAssignableFrom(valueType)) { + return value; + } + + Class boxedDesired = boxIfPrimitive(desiredType); + Class boxedValue = boxIfPrimitive(valueType); + + // Enums + if (boxedDesired.isEnum()) { + return convertToEnum(boxedDesired, value); + } + + // Date/time targets + if (isDateTimeTarget(boxedDesired)) { + return convertToDateTime(boxedDesired, value); + } + + // Collections/Map/JSON + if (Collection.class.isAssignableFrom(boxedDesired) || boxedDesired.isArray() + || Map.class.isAssignableFrom(boxedDesired)) { + return convertToCollectionOrMap(boxedDesired, value); + } + + // String target + if (boxedDesired == String.class) { + return value.toString(); + } + + // Boolean target + if (boxedDesired == Boolean.class) { + return convertToBoolean(value); + } + + // Numeric + if (Number.class.isAssignableFrom(boxedDesired) || isPrimitiveNumber(desiredType)) { + return convertToNumber(boxedDesired, value); + } + + if (boxedDesired == UUID.class) { + return convertToUUID(value); + } + + if (boxedDesired == Character.class) { + return convertToCharacter(value); + } + + if (boxedDesired.isAssignableFrom(boxedValue)) { + return value; + } + + // As a last attempt, try Jackson convert if present + Object conv = tryJacksonConvert(value, desiredType); + if (conv != null) + return conv; + + throw new IllegalArgumentException(String.format("Cannot convert value of type %s to %s (value=%s)", + value.getClass().getName(), desiredType.getName(), value)); + } + + @SuppressWarnings("unchecked") + public static T coerceValue(Class desiredType, Object value) { + return (T) coerceValue(desiredType, (value == null ? null : value.getClass()), value); + } + + private static Class boxIfPrimitive(Class c) { + if (!c.isPrimitive()) + return c; + if (c == int.class) + return Integer.class; + if (c == long.class) + return Long.class; + if (c == short.class) + return Short.class; + if (c == byte.class) + return Byte.class; + if (c == float.class) + return Float.class; + if (c == double.class) + return Double.class; + if (c == boolean.class) + return Boolean.class; + if (c == char.class) + return Character.class; + return c; + } + + private static boolean isPrimitiveNumber(Class c) { + return c == int.class || c == long.class || c == short.class || c == byte.class + || c == float.class || c == double.class; + } + + private static Object defaultPrimitiveValue(Class primitiveType) { + if (primitiveType == boolean.class) + return false; + if (primitiveType == char.class) + return '\0'; + if (primitiveType == byte.class) + return (byte) 0; + if (primitiveType == short.class) + return (short) 0; + if (primitiveType == int.class) + return 0; + if (primitiveType == long.class) + return 0L; + if (primitiveType == float.class) + return 0.0f; + if (primitiveType == double.class) + return 0.0d; + return null; + } + + @SuppressWarnings({"rawtypes", "unchecked", "IfCanBeSwitch"}) + private static Object convertToEnum(Class enumType, Object value) { + + if (value == null) { + return null; + } + + if (value instanceof Enum) { + return value; + } + + if (value instanceof String target) { + String string = target.trim(); + if (string.isEmpty()) { + return null; + } + try { + return Enum.valueOf((Class) enumType, string); + } catch (IllegalArgumentException ex) { + // Try ordinal + try { + int ord = Integer.parseInt(string); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) + return constants[ord]; + } catch (NumberFormatException ignored) { + } + throw ex; + } + } + + if (value instanceof Number number) { + int ord = number.intValue(); + Enum[] constants = (Enum[]) enumType.getEnumConstants(); + if (ord >= 0 && ord < constants.length) { + return constants[ord]; + } + throw new IllegalArgumentException("Enum ordinal out of range: " + ord); + } + + String vs = value.toString(); + if (vs != null && !vs.isEmpty()) { + return convertToEnum(enumType, vs); + } + throw new IllegalArgumentException("Cannot convert to enum: " + value); + } + + private static Object convertToBoolean(Object value) { + if (value instanceof Boolean) + return value; + if (value instanceof Number) { + return ((Number) value).intValue() != 0; + } + String s = value.toString().trim().toLowerCase(Locale.ROOT); + return switch (s) { + case "", "false", "0", "no", "n" -> false; + case "true", "1", "yes", "y" -> true; + default -> throw new IllegalArgumentException("Cannot convert to boolean: " + value); + }; + } + + private static Object convertToNumber(Class targetNumberClass, Object value) { + if (value instanceof Number) { + return castNumber((Number) value, targetNumberClass); + } + String s = value.toString().trim(); + if (s.isEmpty()) { + return castNumber(BigDecimal.ZERO, targetNumberClass); + } + try { + BigDecimal bd = new BigDecimal(s); + return castNumber(bd, targetNumberClass); + } catch (Exception ex) { + throw new IllegalArgumentException("Cannot convert to number: " + value, ex); + } + } + + private static Object castNumber(Number number, Class targetNumberClass) { + if (targetNumberClass == Byte.class) + return number.byteValue(); + if (targetNumberClass == Short.class) + return number.shortValue(); + if (targetNumberClass == Integer.class) + return number.intValue(); + if (targetNumberClass == Long.class) + return number.longValue(); + if (targetNumberClass == Float.class) + return number.floatValue(); + if (targetNumberClass == Double.class) + return number.doubleValue(); + if (targetNumberClass == BigDecimal.class) { + if (number instanceof BigDecimal) + return number; + return BigDecimal.valueOf(number.doubleValue()); + } + if (targetNumberClass.isInstance(number)) + return number; + throw new IllegalArgumentException("Unsupported target numeric type: " + targetNumberClass.getName()); + } + + private static Object convertToUUID(Object value) { + if (value instanceof UUID) { + return value; + } + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + return UUID.fromString(s); + } + + private static Object convertToCharacter(Object value) { + + if (value == null) { + return '\0'; + } + if (value instanceof Character) { + return value; + } + String s = value.toString(); + if (s.isEmpty()) + return '\0'; + return s.charAt(0); + } + + // Extended conversions: date/time, collections, maps, JSON (when Jackson is + // available) + private static boolean isDateTimeTarget(Class boxedDesired) { + return boxedDesired == Date.class || boxedDesired == Instant.class || boxedDesired == LocalDateTime.class + || boxedDesired == LocalDate.class || boxedDesired == LocalTime.class + || boxedDesired == OffsetDateTime.class + || boxedDesired == ZonedDateTime.class; + } + + private static Object convertToDateTime(Class target, Object value) { + if (value == null) + return null; + + if (target.isInstance(value)) + return value; + + if (target == Date.class) { + if (value instanceof Date) + return value; + if (value instanceof Number) + return new Date(((Number) value).longValue()); + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + try { + Instant inst = Instant.parse(s); + return Date.from(inst); + } catch (DateTimeParseException ignored) { + } + try { + long l = Long.parseLong(s); + return new Date(l); + } catch (NumberFormatException ignored) { + } + throw new IllegalArgumentException("Cannot convert to Date: " + value); + } + + if (Temporal.class.isAssignableFrom(target) || target == LocalDate.class || target == LocalDateTime.class + || target == LocalTime.class || target == Instant.class || target == OffsetDateTime.class + || target == ZonedDateTime.class) { + if (value instanceof Number) { + long epoch = ((Number) value).longValue(); + Instant inst = Instant.ofEpochMilli(epoch); + return convertInstantToTarget(target, inst); + } + + String s = value.toString().trim(); + if (s.isEmpty()) + return null; + List> parsers = Arrays.asList( + Instant::parse, + OffsetDateTime::parse, + ZonedDateTime::parse, + LocalDateTime::parse, + LocalDate::parse, + LocalTime::parse); + for (var p : parsers) { + try { + Object parsed = p.apply(s); + + return switch (target.getSimpleName()) { + case "Instant" -> convertInstantToTarget(target, (Instant) parsed); + case "OffsetDateTime" -> convertInstantToTarget(target, ((OffsetDateTime) parsed).toInstant()); + case "ZonedDateTime" -> convertInstantToTarget(target, ((ZonedDateTime) parsed).toInstant()); + case "LocalDateTime" -> convertInstantToTarget(target, + ((LocalDateTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + case "LocalDate" -> convertInstantToTarget(target, + ((LocalDate) parsed).atStartOfDay(ZoneId.systemDefault()).toInstant()); + case "LocalTime" -> convertInstantToTarget(target, LocalDateTime + .of(LocalDate.now(), (LocalTime) parsed).atZone(ZoneId.systemDefault()).toInstant()); + default -> null; + }; + } catch (DateTimeParseException ignored) { + } + } + + try { + long l = Long.parseLong(s); + Instant inst = Instant.ofEpochMilli(l); + return convertInstantToTarget(target, inst); + } catch (NumberFormatException ignored) { + } + + throw new IllegalArgumentException("Cannot parse date/time value: " + value); + } + + throw new IllegalArgumentException("Unsupported date/time target: " + target.getName()); + } + + private static Object convertInstantToTarget(Class target, Instant inst) { + if (target == Instant.class) + return inst; + if (target == LocalDateTime.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == LocalDate.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()).toLocalDate(); + if (target == LocalTime.class) + return LocalDateTime.ofInstant(inst, ZoneId.systemDefault()).toLocalTime(); + if (target == OffsetDateTime.class) + return OffsetDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == ZonedDateTime.class) + return ZonedDateTime.ofInstant(inst, ZoneId.systemDefault()); + if (target == Date.class) + return Date.from(inst); + return null; + } + + @SuppressWarnings("unchecked") + private static Object convertToCollectionOrMap(Class desiredType, Object value) { + if (value == null) + return null; + if (desiredType.isInstance(value)) + return value; + + if (value instanceof Map) { + Object conv = tryJacksonConvert(value, desiredType); + if (conv != null) + return conv; + if (Map.class.isAssignableFrom(desiredType)) + return value; + } + + if (value instanceof Iterable || value.getClass().isArray()) { + List src = new ArrayList<>(); + if (value instanceof Iterable) + for (Object o : (Iterable) value) + src.add(o); + else { + int len = Array.getLength(value); + for (int i = 0; i < len; i++) + src.add(Array.get(value, i)); + } + + if (desiredType.isArray()) { + Class comp = desiredType.getComponentType(); + Object arr = Array.newInstance(comp, src.size()); + for (int i = 0; i < src.size(); i++) { + Array.set(arr, i, coerceValue(comp, src.get(i) == null ? null : src.get(i).getClass(), src.get(i))); + } + return arr; + } + + if (Collection.class.isAssignableFrom(desiredType)) { + try { + Collection coll = (Collection) desiredType.getDeclaredConstructor().newInstance(); + coll.addAll(src); + return coll; + } catch (Exception ex) { + return src; + } + } + } + + if (value instanceof String string) { + String s = string.trim(); + if (s.startsWith("[") || s.startsWith("{")) { + Object parsed = tryJacksonParse(string); + if (parsed != null) { + if (desiredType.isInstance(parsed)) + return parsed; + Object conv = tryJacksonConvert(parsed, desiredType); + if (conv != null) + return conv; + } + } + } + + if (desiredType == Map.class && value instanceof String) { + Object parsed = tryJacksonParse((String) value); + if (parsed instanceof Map) + return parsed; + } + + throw new IllegalArgumentException("Cannot convert to collection/map target: " + desiredType.getName()); + } + + // Reflection-based optional Jackson integration: parse JSON string to Object + // using ObjectMapper + private static Object tryJacksonParse(String json) { + try { + Class mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper"); + Object mapper = mapperClass.getDeclaredConstructor().newInstance(); + java.lang.reflect.Method read = mapperClass.getMethod("readValue", String.class, Class.class); + return read.invoke(mapper, json, Object.class); + } catch (Exception ex) { + return null; + } + } + + private static Object tryJacksonConvert(Object value, Class desiredType) { + try { + Class mapperClass = Class.forName("com.fasterxml.jackson.databind.ObjectMapper"); + Object mapper = mapperClass.getDeclaredConstructor().newInstance(); + java.lang.reflect.Method convert = mapperClass.getMethod("convertValue", Object.class, Class.class); + return convert.invoke(mapper, value, desiredType); + } catch (Exception ex) { + return null; + } + } +} diff --git a/core/src/main/java/com/euonia/security/AccountException.java b/core/src/main/java/com/euonia/security/AccountException.java new file mode 100644 index 0000000..a74f198 --- /dev/null +++ b/core/src/main/java/com/euonia/security/AccountException.java @@ -0,0 +1,97 @@ +package com.euonia.security; + +import java.util.Collections; +import java.util.Map; + +/** + * Exception thrown for account-related errors during authentication or authorization processes, such as account not found, account locked, etc. + * This exception can be extended to provide more specific error types and include additional details as needed. + */ +@SuppressWarnings("unused") +public abstract class AccountException extends RuntimeException { + private final Object identity; + + private final Map details = Collections.emptyMap(); + + /** + * Creates a new AccountException with the specified identity. The identity can be any object that represents the account, such as a username, user ID, or email address. This constructor allows for creating an exception without a specific message or cause, while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + */ + public AccountException(Object identity) { + this.identity = identity; + } + + /** + * Creates a new AccountException with the specified identity and detail message. This constructor allows for creating an exception with a specific message while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + * @param message The detail message explaining the reason for the exception. + */ + public AccountException(Object identity, String message) { + super(message); + this.identity = identity; + } + + /** + * Creates a new AccountException with the specified identity, detail message, and cause. This constructor allows for creating an exception with a specific message and cause while still providing context about which account is involved in the error. + * + * @param identity The identity associated with the account error, such as username or user ID. + * @param message The detail message explaining the reason for the exception. + * @param cause The cause of the exception (which is saved for later retrieval by the getCause() method). + */ + public AccountException(Object identity, String message, Throwable cause) { + super(message, cause); + this.identity = identity; + } + + /** + * Gets the identity associated with the account error. This can be used to provide more context about the error, such as which username or user ID was involved. + * + * @return The identity associated with the account error. + */ + public Object getIdentity() { + return identity; + } + + /** + * Gets additional details related to the account exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @return A map of additional details related to the account exception. + */ + public Map getDetails() { + return details; + } + + /** + * Gets a specific detail from the details map based on the provided key. This can be used to retrieve specific information about the account error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail to retrieve. + * @return The value associated with the specified key in the details map, or null if the key does not exist. + */ + public Object get(String key) { + return details.getOrDefault(key, null); + } + + /** + * Sets a specific detail in the details map with the provided key and value. This can be used to add additional context about the account error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + */ + public void set(String key, Object value) { + details.put(key, value); + } + + /** + * Sets a specific detail in the details map with the provided key and value, and returns the AccountException instance for method chaining. This can be used to add additional context about the account error while allowing for fluent API style when constructing the exception. + * + * @param key The key for the detail. + * @param value The value for the detail. + * @return The AccountException instance, allowing for method chaining. + */ + public AccountException with(String key, Object value) { + details.put(key, value); + return this; + } +} diff --git a/core/src/main/java/com/euonia/security/AuthenticationException.java b/core/src/main/java/com/euonia/security/AuthenticationException.java new file mode 100644 index 0000000..f31445e --- /dev/null +++ b/core/src/main/java/com/euonia/security/AuthenticationException.java @@ -0,0 +1,14 @@ +package com.euonia.security; + +/** + * Exception thrown when authentication fails due to invalid credentials, expired tokens, or other authentication-related issues. + */ +public class AuthenticationException extends RuntimeException { + public AuthenticationException(String message) { + super(message); + } + + public AuthenticationException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/euonia/security/CredentialException.java b/core/src/main/java/com/euonia/security/CredentialException.java new file mode 100644 index 0000000..917abdb --- /dev/null +++ b/core/src/main/java/com/euonia/security/CredentialException.java @@ -0,0 +1,96 @@ +package com.euonia.security; + +import java.util.Collections; +import java.util.Map; + +/** + * The CredentialException is thrown when there is an issue with user credentials, such as invalid username or password. + * This exception indicates that the provided credentials are incorrect or do not meet the required criteria. + */ +@SuppressWarnings("unused") +public abstract class CredentialException extends RuntimeException { + private final Object credential; + private final Map details = Collections.emptyMap(); + + /** + * Create a new CredentialException with the specified credential. + * + * @param credential The credential that caused the exception. + */ + public CredentialException(Object credential) { + this.credential = credential; + } + + /** + * Create a new CredentialException with the specified credential and message. + * + * @param credential The credential that caused the exception. + * @param message The detail message for the exception. + */ + public CredentialException(Object credential, String message) { + super(message); + this.credential = credential; + } + + /** + * Create a new CredentialException with the specified credential, message, and cause. + * + * @param credential The credential that caused the exception. + * @param message The detail message for the exception. + * @param cause The cause of the exception (which is saved for later retrieval by the getCause() method). + */ + public CredentialException(Object credential, String message, Throwable cause) { + super(message, cause); + this.credential = credential; + } + + /** + * Get the credential that caused the exception. This can be used to provide more context about the error, such as which username or password was invalid. + * + * @return The credential that caused the exception. + */ + public Object getCredential() { + return credential; + } + + /** + * Get additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @return A map of additional details related to the credential exception. + */ + public Map getDetails() { + return details; + } + + /** + * Get additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @return The value associated with the key, or null if the key does not exist. + */ + public Object get(String key) { + return details.getOrDefault(key, null); + } + + /** + * Set additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + */ + public void set(String key, Object value) { + details.put(key, value); + } + + /** + * Set additional details related to the credential exception. This can be used to provide more context about the error, such as which field was invalid or what the expected format was. + * + * @param key The key for the detail. + * @param value The value for the detail. + * @return The CredentialException instance, allowing for method chaining. + */ + public CredentialException with(String key, Object value) { + details.put(key, value); + return this; + } +} diff --git a/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java b/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java new file mode 100644 index 0000000..95a6612 --- /dev/null +++ b/core/src/main/java/com/euonia/security/UnauthorizedAccessException.java @@ -0,0 +1,17 @@ +package com.euonia.security; + +/** + * The UnauthorizedAccessException is thrown when an attempt is made to access a resource or perform an action without proper authentication or authorization. + * This exception indicates that the user does not have the necessary credentials or permissions to access the requested resource. + * It is typically used in scenarios where authentication is required but has not been provided, or when the provided credentials are invalid. + */ +@SuppressWarnings("unused") +public class UnauthorizedAccessException extends RuntimeException { + public UnauthorizedAccessException(String message) { + super(message); + } + + public UnauthorizedAccessException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/core/src/main/java/com/euonia/security/UserClaimTypes.java b/core/src/main/java/com/euonia/security/UserClaimTypes.java new file mode 100644 index 0000000..7276fcc --- /dev/null +++ b/core/src/main/java/com/euonia/security/UserClaimTypes.java @@ -0,0 +1,57 @@ +package com.euonia.security; + +/** + * User claim types as defined in OpenID Connect Core 1.0 specification. + * See: https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims + */ +public class UserClaimTypes { + public static final String SUBJECT = "sub"; + public static final String NAME = "name"; + public static final String GIVEN_NAME = "given_name"; + public static final String FAMILY_NAME = "family_name"; + public static final String MIDDLE_NAME = "middle_name"; + public static final String NICKNAME = "nickname"; + public static final String PREFERRED_USER_NAME = "preferred_username"; + public static final String PROFILE = "profile"; + public static final String PICTURE = "picture"; + public static final String WEBSITE = "website"; + public static final String EMAIL = "email"; + public static final String EMAIL_VERIFIED = "email_verified"; + public static final String GENDER = "gender"; + public static final String BIRTHDATE = "birthdate"; + public static final String ZONE_INFO = "zoneinfo"; + public static final String LOCALE = "locale"; + public static final String PHONE_NUMBER = "phone_number"; + public static final String PHONE_NUMBER_VERIFIED = "phone_number_verified"; + public static final String ADDRESS = "address"; + public static final String AUDIENCE = "aud"; + public static final String ISSUER = "iss"; + public static final String NOT_BEFORE = "nbf"; + public static final String EXPIRATION = "exp"; + public static final String ISSUED_AT = "iat"; + public static final String UPDATED_AT = "updated_at"; + public static final String AUTHENTICATION_METHOD = "amr"; + public static final String SESSION_ID = "sid"; + public static final String AUTHENTICATION_CONTEXT_CLASS_REFERENCE = "acr"; + public static final String AUTHENTICATION_TIME = "auth_time"; + public static final String AUTHORIZED_PARTY = "azp"; + public static final String ACCESS_TOKEN_HASH = "at_hash"; + public static final String AUTHORIZATION_CODE_HASH = "c_hash"; + public static final String STATE_HASH = "s_hash"; + public static final String NONCE = "nonce"; + public static final String JWT_ID = "jti"; + public static final String EVENTS = "events"; + public static final String CLIENT_ID = "client_id"; + public static final String SCOPE = "scope"; + public static final String ACTOR = "act"; + public static final String MAY_ACT = "may_act"; + public static final String ID = "id"; + public static final String IDENTITY_PROVIDER = "idp"; + public static final String ROLE = "role"; + public static final String REFERENCE_TOKEN_ID = "reference_token_id"; + public static final String CONFIRMATION = "cnf"; + public static final String CODE = "code"; + public static final String GRANT_TYPE = "grant_type"; + public static final String TENANT = "tenant"; + public static final String SCHEME = "scheme"; +} diff --git a/core/src/main/java/com/euonia/security/UserPrincipal.java b/core/src/main/java/com/euonia/security/UserPrincipal.java new file mode 100644 index 0000000..90024bd --- /dev/null +++ b/core/src/main/java/com/euonia/security/UserPrincipal.java @@ -0,0 +1,84 @@ +package com.euonia.security; + +import javax.security.auth.Subject; + +public class UserPrincipal { + private final Subject subject; + + public UserPrincipal(Subject subject) { + this.subject = subject; + } + + public Subject getSubject() { + return subject; + } + + public String getName() { + return subject.getPrincipals().stream() + .filter(principal -> principal instanceof java.security.Principal) + .map(principal -> ((java.security.Principal) principal).getName()) + .findFirst() + .orElse(null); + } + + public String getClaim(String claimType) { + // return subject.getPrincipals().stream() + // .filter(principal -> principal instanceof UserClaim) + // .map(principal -> (UserClaim) principal) + // .filter(claim -> claim.getType().equals(claimType)) + // .map(UserClaim::getValue) + // .findFirst() + // .orElse(null); + + return null; // Placeholder until UserClaim implementation is added + } + + public boolean isAuthenticated() { + return subject != null && !subject.getPrincipals().isEmpty(); + } + + public boolean hasRole(String role) { + return subject != null && subject.getPrincipals().stream() + .anyMatch(principal -> principal instanceof java.security.Principal && + ((java.security.Principal) principal).getName().equals(role)); + } + + public boolean isInRoles(String... roles) { + return subject != null && subject.getPrincipals().stream() + .anyMatch(principal -> principal instanceof java.security.Principal && + java.util.Arrays.asList(roles).contains(((java.security.Principal) principal).getName())); + } + + public void ensureAuthenticated() { + if (!isAuthenticated()) { + throw new AuthenticationException("User is not authenticated"); + } + } + + public void ensureHasRole(String role) { + ensureAuthenticated(); + + if (subject.getPrincipals().stream() + .noneMatch(principal -> principal instanceof java.security.Principal && + ((java.security.Principal) principal).getName().equals(role))) { + throw new UnauthorizedAccessException("User does not have required role: " + role); + } + } + + public void ensureInRoles(String... roles) { + ensureAuthenticated(); + if (subject.getPrincipals().stream() + .noneMatch(principal -> principal instanceof java.security.Principal && + java.util.Arrays.asList(roles).contains(((java.security.Principal) principal).getName()))) { + throw new UnauthorizedAccessException("User does not have any of the required roles: " + String.join(", ", roles)); + } + } + + // public void ensureHasClaim(String claimType) { + // if (subject == null || subject.getPrincipals().stream() + // .noneMatch(principal -> principal instanceof UserClaim && + // ((UserClaim) principal).getType().equals(claimType))) { + // throw new UnauthorizedAccessException("User does not have required claim: " + claimType); + // } + // } +} diff --git a/core/src/main/java/com/euonia/tuple/Decet.java b/core/src/main/java/com/euonia/tuple/Decet.java new file mode 100644 index 0000000..f1f6d38 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Decet.java @@ -0,0 +1,140 @@ +package com.euonia.tuple; + +import java.util.List; + +/** + * Represents a tuple of ten values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param value10 the tenth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + */ +public record Decet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6, V7 value7, V8 value8, V9 value9, + V10 value10) implements Tuple { + private static final int SIZE = 10; + + /** + * Creates a new Decet instance with the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param value10 the tenth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + * @return a new Decet instance with the specified values + */ + public static Decet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8, + final V9 value9, + final V10 value10) { + return new Decet<>(value1, value2, value3, value4, value5, value6, value7, value8, value9, value10); + } + + /** + * Creates a new Decet instance from an array of values. + * The array must have exactly 10 elements, and the types of the elements will be inferred as the same type for all values. + * + * @param values the array of values + * @param the type of the values + * @return a new Decet instance with the specified values + */ + public static Decet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Decet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7], + values[8], values[9]); + } + + /** + * Creates a new Decet instance from a list of values. + * The list must have exactly 10 elements, and the types of the elements will be inferred as the same type for all values. + * + * @param values the list of values + * @param the type of the values + * @return a new Decet instance with the specified values + */ + public static Decet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Decet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), + values.get(6), values.get(7), values.get(8), values.get(9)); + } + + /** + * Creates an empty Decet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @param the type of the tenth value + * @return a new Decet instance with all values set to null + */ + public static Decet empty() { + return new Decet<>(null, null, null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.List.of(value1, value2, value3, value4, value5, value6, value7, value8, value9, value10); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Duet.java b/core/src/main/java/com/euonia/tuple/Duet.java new file mode 100644 index 0000000..defa601 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Duet.java @@ -0,0 +1,88 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of two values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param the type of the first value + * @param the type of the second value + */ +public record Duet(V1 value1, V2 value2) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 2; + + /** + * Creates a new instance of {@code Duet} with the given values. + * + * @param value1 the first value + * @param value2 the second value + * @param the type of the first value + * @param the type of the second value + * @return a new instance of {@code Duet} with the given values + */ + public static Duet of(V1 value1, V2 value2) { + return new Duet<>(value1, value2); + } + + /** + * Creates a new instance of {@code Duet} from the given values. + * + * @param values an array of values + * @param the type of the values + * @return a new instance of {@code Duet} with the given values + */ + public static Duet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Duet<>(values[0], values[1]); + } + + /** + * Creates a new instance of {@code Duet} from the given values. + * + * @param values a list of values + * @param the type of the values + * @return a new instance of {@code Duet} with the given values + */ + public static Duet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Duet<>(values.get(0), values.get(1)); + } + + /** + * Creates a new instance of {@code Duet} with null values. + * + * @param the type of the first value + * @param the type of the second value + * @return a new instance of {@code Duet} with null values + */ + public static Duet empty() { + return new Duet<>(null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Nonet.java b/core/src/main/java/com/euonia/tuple/Nonet.java new file mode 100644 index 0000000..19b57cd --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Nonet.java @@ -0,0 +1,136 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of nine values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + */ +public record Nonet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6, V7 value7, V8 value8, V9 value9) implements Tuple { + + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 9; + + /** + * Creates a new Nonet instance from the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param value9 the ninth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @return a new Nonet instance with the specified values + */ + public static Nonet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8, + final V9 value9) { + return new Nonet<>(value1, value2, value3, value4, value5, value6, value7, value8, value9); + } + + /** + * Creates a new Nonet instance from the specified array of values. + * + * @param values the array of values + * @param the type of the values + * @return a new Nonet instance with the specified values + */ + public static Nonet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Nonet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7], + values[8]); + } + + /** + * Creates a new Nonet instance from the specified list of values. + * + * @param values the list of values + * @param the type of the values + * @return a new Nonet instance with the specified values + */ + public static Nonet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Nonet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), + values.get(6), values.get(7), values.get(8)); + } + + /** + * Creates a new Nonet instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @param the type of the ninth value + * @return a new Nonet instance with null values + */ + public static Nonet empty() { + return new Nonet<>(null, null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7, value8, value9); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Octet.java b/core/src/main/java/com/euonia/tuple/Octet.java new file mode 100644 index 0000000..45fd324 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Octet.java @@ -0,0 +1,123 @@ +package com.euonia.tuple; + +import java.util.List; + +/** + * Represents a tuple of eight values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + */ +public record Octet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6, + V7 value7, V8 value8) implements Tuple { + private static final int SIZE = 8; + + /** + * Creates a new Octet instance with the specified values. + * + * @param value1 the first value + * @param value2 the second value + * @param value3 the third value + * @param value4 the fourth value + * @param value5 the fifth value + * @param value6 the sixth value + * @param value7 the seventh value + * @param value8 the eighth value + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @return a new Octet instance with the specified values + */ + public static Octet of( + final V1 value1, + final V2 value2, + final V3 value3, + final V4 value4, + final V5 value5, + final V6 value6, + final V7 value7, + final V8 value8) { + return new Octet<>(value1, value2, value3, value4, value5, value6, value7, value8); + } + + /** + * Creates a new Octet instance from the specified array of values. + * + * @param values the array of values + * @param the type of the values + * @return a new Octet instance + */ + public static Octet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Octet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6], values[7]); + } + + /** + * Creates a new Octet instance from the specified list of values. + * + * @param values the list of values + * @param the type of the values + * @return a new Octet instance + */ + public static Octet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Octet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), values.get(6), values.get(7)); + } + + /** + * Creates a new Octet instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @param the type of the eighth value + * @return a new Octet instance with all values set to null + */ + public static Octet empty() { + return new Octet<>(null, null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7, value8); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Quartet.java b/core/src/main/java/com/euonia/tuple/Quartet.java new file mode 100644 index 0000000..89d0a19 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Quartet.java @@ -0,0 +1,98 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of four values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + */ +public record Quartet(V1 value1, V2 value2, V3 value3, V4 value4) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 4; + + /** + * Creates a new Quartet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @return a new Quartet instance with the specified values + */ + public static Quartet of(V1 value1, V2 value2, V3 value3, V4 value4) { + return new Quartet<>(value1, value2, value3, value4); + } + + /** + * Creates a new Quartet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Quartet instance with the specified values + */ + public static Quartet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quartet<>(values[0], values[1], values[2], values[3]); + } + + /** + * Creates a new Quartet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Quartet instance with the specified values + */ + public static Quartet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quartet<>(values.get(0), values.get(1), values.get(2), values.get(3)); + } + + /** + * Creates an empty Quartet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @return a new Quartet instance with all values set to null + */ + public static Quartet empty() { + return new Quartet<>(null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Quintet.java b/core/src/main/java/com/euonia/tuple/Quintet.java new file mode 100644 index 0000000..23d3e3a --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Quintet.java @@ -0,0 +1,104 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of five values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + */ +public record Quintet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 5; + + /** + * Creates a new Quintet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @return a new Quintet instance with the specified values + */ + public static Quintet of(V1 value1, V2 value2, V3 value3, V4 value4, + V5 value5) { + return new Quintet<>(value1, value2, value3, value4, value5); + } + + /** + * Creates a new Quintet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Quintet instance with the specified values + */ + public static Quintet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quintet<>(values[0], values[1], values[2], values[3], values[4]); + } + + /** + * Creates a new Quintet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Quintet instance with the specified values + */ + public static Quintet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Quintet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4)); + } + + /** + * Creates a new Quintet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @return a new Quintet instance with all values set to null + */ + public static Quintet empty() { + return new Quintet<>(null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Septet.java b/core/src/main/java/com/euonia/tuple/Septet.java new file mode 100644 index 0000000..903ed89 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Septet.java @@ -0,0 +1,115 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of seven values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param value7 the seventh value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + */ +public record Septet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6, + V7 value7) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 7; + + /** + * Creates a new Septet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param value7 the seventh value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @return a new Septet instance with the specified values + */ + public static Septet of(V1 value1, V2 value2, V3 value3, V4 value4, + V5 value5, V6 value6, V7 value7) { + return new Septet<>(value1, value2, value3, value4, value5, value6, value7); + } + + /** + * Creates a new Septet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Septet instance with the specified values + */ + public static Septet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Septet<>(values[0], values[1], values[2], values[3], values[4], values[5], values[6]); + } + + /** + * Creates a new Septet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Septet instance with the specified values + */ + public static Septet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Septet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5), values.get(6)); + } + + /** + * Creates a new Septet instance with all values set to null. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @param the type of the seventh value + * @return a new Septet instance with all values set to null + */ + public static Septet empty() { + return new Septet<>(null, null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3, value4, value5, value6, value7); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Sextet.java b/core/src/main/java/com/euonia/tuple/Sextet.java new file mode 100644 index 0000000..a87db46 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Sextet.java @@ -0,0 +1,98 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of six values, which can be of different types. + * This class is immutable and provides methods to access the values and perform common tuple operations. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + */ +public record Sextet(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, + V6 value6) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 6; + + /** + * Creates a new Sextet instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param value4 the fourth value of the tuple + * @param value5 the fifth value of the tuple + * @param value6 the sixth value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @param the type of the fourth value + * @param the type of the fifth value + * @param the type of the sixth value + * @return a new Sextet instance with the specified values + */ + public static Sextet of(V1 value1, V2 value2, V3 value3, V4 value4, V5 value5, V6 value6) { + return new Sextet<>(value1, value2, value3, value4, value5, value6); + } + + /** + * Creates a new Sextet instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Sextet instance with the specified values + */ + public static Sextet from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Sextet<>(values[0], values[1], values[2], values[3], values[4], values[5]); + } + + /** + * Creates a new Sextet instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Sextet instance with the specified values + */ + public static Sextet from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Sextet<>(values.get(0), values.get(1), values.get(2), values.get(3), values.get(4), values.get(5)); + } + + public static Sextet empty() { + return new Sextet<>(null, null, null, null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.List.of(value1, value2, value3, value4, value5, value6); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Solo.java b/core/src/main/java/com/euonia/tuple/Solo.java new file mode 100644 index 0000000..359af83 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Solo.java @@ -0,0 +1,78 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * Represents a tuple of a single value, which can be of any type. + * This class is immutable and provides methods to access the value and perform common tuple operations. + * + * @param value the value of the tuple + * @param the type of the value + */ +public record Solo(V value) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 1; + + /** + * Creates a new Solo instance with the specified value. + * + * @param value the value of the tuple + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo of(V value) { + return new Solo<>(value); + } + + /** + * Creates a new Solo instance from the specified value. + * + * @param values the array of values + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Solo<>(values[0]); + } + + /** + * Creates a new Solo instance from the specified value. + * + * @param values the list of values + * @param the type of the value + * @return a new Solo instance with the specified value + */ + public static Solo from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Solo<>(values.get(0)); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public java.util.List values() { + return java.util.Collections.singletonList(value); + } + + @Override + public boolean contains(Object value) { + return java.util.Objects.equals(this.value, value); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Trio.java b/core/src/main/java/com/euonia/tuple/Trio.java new file mode 100644 index 0000000..0c8c26a --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Trio.java @@ -0,0 +1,92 @@ +package com.euonia.tuple; + +import java.io.Serial; +import java.util.List; + +/** + * A tuple of three values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + */ +public record Trio(T1 value1, T2 value2, T3 value3) implements Tuple { + @Serial + private static final long serialVersionUID = 1L; + + private static final int SIZE = 3; + + /** + * Creates a new Trio instance with the specified values. + * + * @param value1 the first value of the tuple + * @param value2 the second value of the tuple + * @param value3 the third value of the tuple + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @return a new Trio instance with the specified values + */ + public static Trio of(T1 value1, T2 value2, T3 value3) { + return new Trio<>(value1, value2, value3); + } + + /** + * Creates a new Trio instance from the specified values. + * + * @param values the array of values + * @param the type of the values + * @return a new Trio instance with the specified values + */ + public static Trio from(final X[] values) { + if (values == null || values.length == 0) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.length != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Trio<>(values[0], values[1], values[2]); + } + + /** + * Creates a new Trio instance from the specified values. + * + * @param values the list of values + * @param the type of the values + * @return a new Trio instance with the specified values + */ + public static Trio from(final List values) { + if (values == null || values.isEmpty()) { + throw new IllegalArgumentException("values must not be empty"); + } + if (values.size() != SIZE) { + throw new IllegalArgumentException("values must have same size"); + } + return new Trio<>(values.get(0), values.get(1), values.get(2)); + } + + /** + * Creates a new Trio instance with null values. + * + * @param the type of the first value + * @param the type of the second value + * @param the type of the third value + * @return a new Trio instance with null values + */ + public static Trio empty() { + return new Trio<>(null, null, null); + } + + @Override + public int size() { + return SIZE; + } + + @Override + public List values() { + return List.of(value1, value2, value3); + } +} diff --git a/core/src/main/java/com/euonia/tuple/Tuple.java b/core/src/main/java/com/euonia/tuple/Tuple.java new file mode 100644 index 0000000..a1bc216 --- /dev/null +++ b/core/src/main/java/com/euonia/tuple/Tuple.java @@ -0,0 +1,242 @@ +package com.euonia.tuple; + +import java.io.Serializable; +import java.util.*; + +/** + * Represents an ordered collection of values, where each value can be of any type. A tuple is immutable and can contain duplicate values. + * It provides methods to access the values, check for their presence, and perform common tuple operations such as equality checks and conversions to lists and arrays. + * The Tuple interface extends Iterable to allow for easy iteration over the values in the tuple, and it also implements Serializable to enable serialization of tuple instances. + * The Tuple interface is designed to be flexible and can be implemented by various classes that represent tuples of different sizes and types, such as Pair, Triplet, Quadruple, etc. + * It provides a common contract for working with tuples, allowing for consistent handling of tuples across different implementations and use cases. + */ +@SuppressWarnings("unused") +public interface Tuple extends Iterable, Serializable { + /** + * Returns the number of values in the tuple. + * + * @return the number of values in the tuple + */ + int size(); + + /** + * Returns the value at the specified index in the tuple. + * + * @param index the index of the value to return + * @return the value at the specified index + * @throws IndexOutOfBoundsException if the index is out of range + */ + default Object value(int index) { + if (index < 0 || index >= size()) { + throw new IndexOutOfBoundsException("Index: " + index + ", Size: " + size()); + } + return values().get(index); + } + + /** + * Returns a list of all values in the tuple. + * + * @return a list of all values in the tuple + */ + List values(); + + /** + * Checks if the tuple contains the specified value. + * + * @param value the value to check for + * @return true if the tuple contains the value, false otherwise + */ + default boolean contains(final Object value) { + return values().stream().anyMatch(v -> Objects.equals(v, value)); + } + + /** + * Checks if the tuple contains all of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Collection values) { + for (final Object value : values) { + if (!contains(value)) { + return false; + } + } + return true; + } + + /** + * Checks if the tuple contains all of the values in the specified tuple. + * + * @param tuple the tuple whose values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Tuple tuple) { + return containsAll(tuple.values()); + } + + /** + * Checks if the tuple contains all of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains all of the values, false otherwise + */ + default boolean containsAll(final Object... values) { + for (final Object value : values) { + if (!contains(value)) { + return false; + } + } + return true; + } + + /** + * Checks if the tuple contains any of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Collection values) { + for (final Object value : values) { + if (contains(value)) { + return true; + } + } + return false; + } + + /** + * Checks if the tuple contains any of the values in the specified tuple. + * + * @param tuple the tuple whose values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Tuple tuple) { + return containsAny(tuple.values()); + } + + /** + * Checks if the tuple contains any of the specified values. + * + * @param values the values to check for + * @return true if the tuple contains any of the values, false otherwise + */ + default boolean containsAny(final Object... values) { + for (final Object value : values) { + if (contains(value)) { + return true; + } + } + return false; + } + + /** + * Returns the index of the first occurrence of the specified value in the tuple, or -1 if the value is not found. + * + * @param value the value to search for + * @return the index of the first occurrence of the specified value, or -1 if not found + */ + default int indexOf(final Object value) { + final List values = values(); + final int size = this.size(); + for (int i = 0; i < size; i++) { + if (Objects.equals(values.get(i), value)) { + return i; + } + } + return -1; + } + + /** + * Returns the index of the last occurrence of the specified value in the tuple, or -1 if the value is not found. + * + * @param value the value to search for + * @return the index of the last occurrence of the specified value, or -1 if not found + */ + default int lastIndexOf(final Object value) { + final List values = values(); + for (int i = this.size() - 1; i >= 0; i--) { + if (Objects.equals(values.get(i), value)) { + return i; + } + } + return -1; + } + + /** + * Returns a list containing all values in the tuple. The returned list is immutable and reflects the order of values in the tuple. + * + * @return a list containing all values in the tuple + */ + default List toList() { + return this.values(); // already returns an immutable list + } + + /** + * Returns an array containing all values in the tuple. The returned array is a new array and reflects the order of values in the tuple. + * + * @return an array containing all values in the tuple + */ + default Object[] toArray() { + return this.values().toArray(); + } + + /** + * Returns an array containing all values in the tuple. The returned array is array new array and reflects the order of values in the tuple. If the provided array is large enough to hold all values, it will be used; otherwise, array new array of the same runtime type will be allocated. + * + * @param array the array into which the elements of the tuple are to be stored, if it is big enough; otherwise, array new array of the same runtime type is allocated for this purpose + * @param the component type of the array to contain the tuple values + * @return an array containing all values in the tuple + */ + default X[] toArray(final X[] array) { + return this.values().toArray(array); + } + + /** + * Returns an iterator over the values in the tuple. The returned iterator is immutable and reflects the order of values in the tuple. + * + * @return an iterator over the values in the tuple + */ + @SuppressWarnings("NullableProblems") + @Override + default Iterator iterator() { + return values().iterator(); + } + + /** + * Checks if this tuple is equal to another tuple, ignoring the order of values. Two tuples are considered equal if they contain the same values with the same number of occurrences, regardless of their order. + * + * @param other the other tuple to compare with + * @return true if the tuples are equal ignoring the order of values, false otherwise + */ + default boolean equalsIgnoreOrder(final Tuple other) { + + if (this == other) { + return true; + } + final int size = this.size(); + if (other == null || size != other.size()) { + return false; + } + + // Store every element along with the number of times it occurs, then use the other map to decrease + final Map valueOccurrences = new HashMap<>(size + 1, 1.0f); + + for (int i = 0; i < size; i++) { + final Object thisValue = this.value(i); + valueOccurrences.merge(thisValue, 1, Integer::sum); + } + + for (int i = 0; i < size; i++) { + final Object otherValue = other.value(i); + int occ = valueOccurrences.merge(otherValue, -1, Integer::sum); + if (occ < 0) { + return false; // element is not present: tuples are different + } else if (occ == 0) { + valueOccurrences.remove(otherValue); + } + } + + return valueOccurrences.isEmpty(); + } +} diff --git a/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java b/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java new file mode 100644 index 0000000..edd997a --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/RequiredAnnotationTest.java @@ -0,0 +1,44 @@ +package com.euonia.annotation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Required annotation") +class RequiredAnnotationTest { + + @Required + private String sampleField; + + @Test + @DisplayName("Given Required definition when inspecting metadata then target and retention are correct") + void givenRequiredDefinitionWhenInspectingThenMetadataIsCorrect() { + Target target = Required.class.getAnnotation(Target.class); + Retention retention = Required.class.getAnnotation(Retention.class); + Validation validation = Required.class.getAnnotation(Validation.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.FIELD, ElementType.PARAMETER}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + assertNotNull(validation); + assertEquals(RequiredValidator.class, validation.validator()); + } + + @Test + @DisplayName("Given default Required usage when reading annotation then default attributes are applied") + void givenDefaultRequiredUsageWhenReadingThenDefaultAttributesApplied() throws NoSuchFieldException { + Required annotation = RequiredAnnotationTest.class.getDeclaredField("sampleField").getAnnotation(Required.class); + + assertNotNull(annotation); + assertFalse(annotation.allowEmpty()); + assertEquals("", annotation.message()); + assertEquals(Required.class, annotation.annotation()); + } +} + diff --git a/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java b/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java new file mode 100644 index 0000000..7fbdf23 --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/RequiredValidatorTest.java @@ -0,0 +1,80 @@ +package com.euonia.annotation; + +import com.euonia.tuple.Duet; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("RequiredValidator") +class RequiredValidatorTest { + + private final RequiredValidator validator = new RequiredValidator(); + + private static class Samples { + @Required + private String defaultRequired; + + @Required(allowEmpty = true) + private String allowEmpty; + + @Required(message = "custom required") + private String customMessage; + } + + @Test + @DisplayName("Given null value when validating required field then validation fails with default message") + void givenNullValueWhenValidatingThenFailWithDefaultMessage() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, null); + + assertFalse(result.value1()); + assertEquals("Value is required", result.value2()); + } + + @Test + @DisplayName("Given empty string and allowEmpty false when validating then validation fails") + void givenEmptyStringAndAllowEmptyFalseWhenValidatingThenFail() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, ""); + + assertFalse(result.value1()); + assertEquals("Value must not be empty", result.value2()); + } + + @Test + @DisplayName("Given empty string and allowEmpty true when validating then validation succeeds") + void givenEmptyStringAndAllowEmptyTrueWhenValidatingThenSucceed() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("allowEmpty").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, ""); + + assertTrue(result.value1()); + assertEquals("", result.value2()); + } + + @Test + @DisplayName("Given null value and custom message when validating then custom message is returned") + void givenNullValueAndCustomMessageWhenValidatingThenReturnCustomMessage() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("customMessage").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, null); + + assertFalse(result.value1()); + assertEquals("custom required", result.value2()); + } + + @Test + @DisplayName("Given non-empty value when validating then validation succeeds") + void givenNonEmptyValueWhenValidatingThenSucceed() throws NoSuchFieldException { + Required annotation = Samples.class.getDeclaredField("defaultRequired").getAnnotation(Required.class); + + Duet result = validator.validate(annotation, "abc"); + + assertTrue(result.value1()); + assertEquals("", result.value2()); + } +} + diff --git a/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java b/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java new file mode 100644 index 0000000..56e444e --- /dev/null +++ b/core/src/test/java/com/euonia/annotation/ValidationAnnotationTest.java @@ -0,0 +1,36 @@ +package com.euonia.annotation; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Validation annotation") +class ValidationAnnotationTest { + + @Test + @DisplayName("Given Validation definition when inspecting metadata then target and retention are correct") + void givenValidationDefinitionWhenInspectingThenMetadataIsCorrect() { + Target target = Validation.class.getAnnotation(Target.class); + Retention retention = Validation.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.ANNOTATION_TYPE}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given Required annotation when reading Validation meta then validator type is RequiredValidator") + void givenRequiredAnnotationWhenReadingValidationMetaThenValidatorTypeMatches() { + Validation validation = Required.class.getAnnotation(Validation.class); + + assertNotNull(validation); + assertEquals(RequiredValidator.class, validation.validator()); + } +} + diff --git a/core/src/test/java/com/euonia/core/ObjectIdTest.java b/core/src/test/java/com/euonia/core/ObjectIdTest.java new file mode 100644 index 0000000..adea74b --- /dev/null +++ b/core/src/test/java/com/euonia/core/ObjectIdTest.java @@ -0,0 +1,123 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ObjectId") +class ObjectIdTest { + + @Test + @DisplayName("Given primitive and common object values when constructing ObjectId then value is preserved") + void givenSupportedValueTypesWhenConstructingThenValueIsPreserved() { + ObjectId fromLong = new ObjectId(42L); + ObjectId fromString = new ObjectId("abc"); + UUID uuid = UUID.randomUUID(); + ObjectId fromUuid = new ObjectId(uuid); + ObjectId fromInteger = new ObjectId(Integer.valueOf(7)); + + assertEquals(42L, fromLong.getValue()); + assertEquals("abc", fromString.getValue()); + assertEquals(uuid, fromUuid.getValue()); + assertEquals(7, fromInteger.getValue()); + } + + @Test + @DisplayName("Given int literal when constructing ObjectId then long constructor is selected") + void givenIntLiteralWhenConstructingThenLongConstructorIsSelected() { + ObjectId objectId = new ObjectId(7); + + assertInstanceOf(Long.class, objectId.getValue()); + assertEquals(7L, objectId.getValue()); + } + + @Test + @DisplayName("Given generated ids when calling static factory methods then generated value types are correct") + void givenFactoriesWhenGeneratingThenReturnExpectedUnderlyingTypes() { + ObjectId snowflake = ObjectId.snowflake(); + ObjectId guid = ObjectId.guid(); + ObjectId random = ObjectId.random(); + ObjectId ulid = ObjectId.ulid(); + + assertInstanceOf(Long.class, snowflake.getValue()); + assertInstanceOf(UUID.class, guid.getValue()); + assertInstanceOf(UUID.class, random.getValue()); + assertInstanceOf(String.class, ulid.getValue()); + assertEquals(32, ulid.toString().length()); + } + + @Test + @DisplayName("Given type token when value matches then getValue(Class) returns cast value") + void givenMatchingTypeWhenGetValueByTypeThenReturnsCastedValue() { + UUID uuid = UUID.randomUUID(); + ObjectId objectId = new ObjectId(uuid); + + UUID casted = objectId.getValue(UUID.class); + + assertEquals(uuid, casted); + } + + @Test + @DisplayName("Given type token when value does not match then getValue(Class) returns null") + void givenMismatchedTypeWhenGetValueByTypeThenReturnsNull() { + ObjectId objectId = new ObjectId("value"); + + Integer casted = objectId.getValue(Integer.class); + + assertNull(casted); + } + + @Test + @DisplayName("Given identical underlying values when comparing ObjectId then equals and hashCode are consistent") + void givenSameUnderlyingValueWhenComparingThenEqualsAndHashCodeMatch() { + ObjectId left = new ObjectId("same"); + ObjectId right = new ObjectId("same"); + + assertEquals(left, right); + assertEquals(left.hashCode(), right.hashCode()); + } + + @Test + @DisplayName("Given different values or types when comparing ObjectId then equals returns false") + void givenDifferentValuesWhenComparingThenNotEqual() { + ObjectId left = new ObjectId("left"); + ObjectId right = new ObjectId("right"); + ObjectId longNumeric = new ObjectId(7L); + ObjectId integerNumeric = new ObjectId(Integer.valueOf(7)); + + assertEquals(left, left); + assertNotEquals(left, right); + assertNotEquals(left, null); + assertNotEquals(left, "left"); + assertNotEquals(longNumeric, integerNumeric); + } + + @Test + @DisplayName("Given supported underlying value types when converting to string then text representation is returned") + void givenSupportedValueTypesWhenToStringThenReturnExpectedText() { + UUID uuid = UUID.randomUUID(); + ObjectId longId = new ObjectId(123L); + ObjectId integerId = new ObjectId(456); + ObjectId stringId = new ObjectId("hello"); + ObjectId uuidId = new ObjectId(uuid); + + assertEquals("123", longId.toString()); + assertEquals("456", integerId.toString()); + assertEquals("hello", stringId.toString()); + assertEquals(uuid.toString(), uuidId.toString()); + } + + @Test + @DisplayName("Given different UUID-based factories when generating then returned values are valid UUID instances") + void givenGuidAndRandomFactoriesWhenGeneratingThenReturnUuidValues() { + ObjectId guid = ObjectId.guid(); + ObjectId random = ObjectId.random(); + + assertDoesNotThrow(() -> UUID.fromString(guid.toString())); + assertDoesNotThrow(() -> UUID.fromString(random.toString())); + } +} + diff --git a/core/src/test/java/com/euonia/core/PairTest.java b/core/src/test/java/com/euonia/core/PairTest.java new file mode 100644 index 0000000..0974a37 --- /dev/null +++ b/core/src/test/java/com/euonia/core/PairTest.java @@ -0,0 +1,26 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Pair") +class PairTest { + + @Test + @DisplayName("Given key and value when creating pair then values are stored") + void givenKeyAndValueWhenCreatingThenValuesAreStored() { + Pair pair = Pair.of(1, "one"); + + assertEquals(1, pair.key()); + assertEquals("one", pair.value()); + } + + @Test + @DisplayName("Given null key when creating pair then illegal argument is thrown") + void givenNullKeyWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Pair.of(null, "value")); + } +} + diff --git a/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java new file mode 100644 index 0000000..4a38a05 --- /dev/null +++ b/core/src/test/java/com/euonia/core/PriorityValueFinderTest.java @@ -0,0 +1,90 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.function.Supplier; +import java.util.random.RandomGenerator; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("PriorityValueFinder") +class PriorityValueFinderTest { + + @Test + @DisplayName("Given queue and predicate when matching element exists then matching value is returned") + void givenQueueAndPredicateWhenMatchExistsThenReturnMatch() { + PriorityQueue, Integer> queue = new PriorityQueue<>(); + queue.add(() -> 3, 3); + queue.add(() -> 1, 1); + queue.add(() -> 2, 2); + + Integer result = PriorityValueFinder.find(queue, value -> value >= 2, -1); + + assertEquals(2, result); + } + + @Test + @DisplayName("Given queue and predicate when no element matches then default value is returned") + void givenQueueAndPredicateWhenNoMatchThenReturnDefault() { + PriorityQueue, Integer> queue = new PriorityQueue<>(); + queue.add(() -> 1, 1); + queue.add(() -> 2, 2); + + Integer result = PriorityValueFinder.find(queue, value -> value > 10, -1); + + assertEquals(-1, result); + } + + @Test + @DisplayName("Given empty queue when finding then default value is returned") + void givenEmptyQueueWhenFindingThenReturnDefault() { + PriorityQueue, Integer> queue = new PriorityQueue<>(); + + Integer result = PriorityValueFinder.find(queue, value -> true, 99); + + assertEquals(99, result); + } + + @Test + @DisplayName("Given supplier and consumer overloads when finding then expected value is returned") + void givenSupplierAndConsumerOverloadsWhenFindingThenReturnExpectedValue() { + Integer supplierResult = PriorityValueFinder.find(queue -> { + queue.add(() -> 5, 5); + queue.add(() -> 9, 9); + }, value -> value > 6, -1); + + Integer consumerResult = PriorityValueFinder.find(queue -> { + queue.add(() -> 8, 8); + queue.add(() -> 4, 4); + }, value -> value % 2 == 0 && value > 5, -1); + + assertEquals(9, supplierResult); + assertEquals(8, consumerResult); + } + + @Test + void testComparator() { + String random = RandomGenerator.getDefault().toString(); + String ulid = ObjectId.ulid().toString(); + String uuid = UUID.randomUUID().toString(); + String value = PriorityValueFinder.find(queue -> { + queue.add(() -> "test", 1); + queue.add(() -> ulid, 2); + queue.add(() -> random, 3); + queue.add(() -> uuid, 4); + }, v -> v.length() > 20, "default"); + assertEquals(ulid, value); + } + +// @Test +// @DisplayName("Given invalid arguments when finding then illegal argument is thrown") +// void givenInvalidArgumentsWhenFindingThenThrowIllegalArgument() { +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((PriorityQueue) null, v -> true, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find(new PriorityQueue, Integer>(), null, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Supplier, Integer>>) null, v -> true, 0)); +// assertThrows(IllegalArgumentException.class, () -> PriorityValueFinder.find((java.util.function.Consumer, Integer>>) null, v -> true, 0)); +// } +} + diff --git a/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java b/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java new file mode 100644 index 0000000..e733b9d --- /dev/null +++ b/core/src/test/java/com/euonia/core/ShortUniqueIdTest.java @@ -0,0 +1,95 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ShortUniqueId") +class ShortUniqueIdTest { + + @Test + @DisplayName("Given default instance accessor when calling twice then same instance is returned") + void givenDefaultAccessorWhenCallingTwiceThenReturnSameInstance() { + assertSame(ShortUniqueId.getDefault(), ShortUniqueId.getDefault()); + } + + @Test + @DisplayName("Given numbers when encoding and decoding then original values are restored") + void givenNumbersWhenEncodingAndDecodingThenRestoreOriginalValues() { + ShortUniqueId hashId = new ShortUniqueId(); + + String encodedInt = hashId.encode(12345); + String encodedLongs = hashId.encode(1L, 2L, 300L); + String encodedCollection = hashId.encode(List.of(7, 8, 9)); + + assertArrayEquals(new int[]{12345}, hashId.decode(encodedInt)); + assertArrayEquals(new long[]{1L, 2L, 300L}, hashId.decodeLong(encodedLongs)); + assertArrayEquals(new int[]{7, 8, 9}, hashId.decode(encodedCollection)); + } + + @Test + @DisplayName("Given invalid inputs when encoding longs then empty hash is returned") + void givenInvalidLongInputsWhenEncodingThenReturnEmptyHash() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertEquals("", hashId.encode((long[]) null)); + assertEquals("", hashId.encode(new long[]{})); + assertEquals("", hashId.encode(1L, -2L)); + } + + @Test + @DisplayName("Given blank hash when decoding then empty arrays are returned") + void givenBlankHashWhenDecodingThenReturnEmptyArrays() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertArrayEquals(new int[0], hashId.decode(" ")); + assertArrayEquals(new long[0], hashId.decodeLong(" ")); + } + + @Test + @DisplayName("Given hexadecimal string when encoding and decoding then original uppercase hex is restored") + void givenHexStringWhenEncodingAndDecodingThenRestoreUppercaseHex() { + ShortUniqueId hashId = new ShortUniqueId(); + String hex = "deadbeefcafebabe"; + + String encoded = hashId.encodeHex(hex); + String decoded = hashId.decodeHex(encoded); + + assertFalse(encoded.isBlank()); + assertEquals(hex.toUpperCase(), decoded); + } + + @Test + @DisplayName("Given invalid hex when encoding hex then empty hash is returned") + void givenInvalidHexWhenEncodingHexThenReturnEmptyHash() { + ShortUniqueId hashId = new ShortUniqueId(); + + assertEquals("", hashId.encodeHex(null)); + assertEquals("", hashId.encodeHex("")); + assertEquals("", hashId.encodeHex("xyz-123")); + } + + @Test + @DisplayName("Given min hash length when encoding then resulting hash respects minimum size") + void givenMinHashLengthWhenEncodingThenHashRespectsMinimumSize() { + ShortUniqueId hashId = new ShortUniqueId("salt", 16, "abcdefghijklmnopqrstuvwxyzABCDEFG", "cfhistuCFHISTU"); + + String encoded = hashId.encode(1); + + assertTrue(encoded.length() >= 16); + } + + @Test + @DisplayName("Given invalid constructor arguments when creating then illegal argument is thrown") + void givenInvalidConstructorArgumentsWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId(null, 0, "abcdefghijklmnopqrstuvwxyz", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", -1, "abcdefghijklmnopqrstuvwxyz", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "abc", "cfhistu")); + assertThrows(IllegalArgumentException.class, () -> new ShortUniqueId("", 0, "abcdefghijklmnopqrstuvwxyz", "")); + } +} + diff --git a/core/src/test/java/com/euonia/core/SingletonTest.java b/core/src/test/java/com/euonia/core/SingletonTest.java new file mode 100644 index 0000000..04054b5 --- /dev/null +++ b/core/src/test/java/com/euonia/core/SingletonTest.java @@ -0,0 +1,67 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Singleton") +class SingletonTest { + + @Test + @DisplayName("Given class with default constructor when getting instance twice then same instance is returned") + void givenClassWithDefaultConstructorWhenGettingTwiceThenReturnSameInstance() { + SampleService first = Singleton.getInstance(SampleService.class); + SampleService second = Singleton.getInstance(SampleService.class); + + assertSame(first, second); + } + + @Test + @DisplayName("Given class and supplier when getting twice then supplier runs once and same instance is returned") + void givenClassAndSupplierWhenGettingTwiceThenSupplierRunsOnce() { + AtomicInteger counter = new AtomicInteger(); + + ProvidedService first = Singleton.get(ProvidedService.class, () -> { + counter.incrementAndGet(); + return new ProvidedService("v1"); + }); + ProvidedService second = Singleton.get(ProvidedService.class, () -> { + counter.incrementAndGet(); + return new ProvidedService("v2"); + }); + + assertSame(first, second); + assertEquals("v1", first.value); + assertEquals(1, counter.get()); + } + + @Test + @DisplayName("Given class without default constructor when getting instance then runtime exception is thrown") + void givenClassWithoutDefaultConstructorWhenGettingInstanceThenThrowRuntimeException() { + RuntimeException exception = assertThrows(RuntimeException.class, () -> Singleton.getInstance(NoDefaultConstructor.class)); + + assertTrue(exception.getMessage().contains(NoDefaultConstructor.class.getName())); + } + + static class SampleService { + SampleService() { + } + } + + static class ProvidedService { + private final String value; + + ProvidedService(String value) { + this.value = value; + } + } + + static class NoDefaultConstructor { + NoDefaultConstructor(String value) { + } + } +} + diff --git a/core/src/test/java/com/euonia/core/SnowflakeIdTest.java b/core/src/test/java/com/euonia/core/SnowflakeIdTest.java new file mode 100644 index 0000000..f90cffe --- /dev/null +++ b/core/src/test/java/com/euonia/core/SnowflakeIdTest.java @@ -0,0 +1,51 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("SnowflakeId") +class SnowflakeIdTest { + + @Test + @DisplayName("Given valid worker and datacenter when generating ids then ids are unique and increasing") + void givenValidWorkerAndDatacenterWhenGeneratingThenIdsAreUniqueAndIncreasing() { + SnowflakeId generator = SnowflakeId.getInstance(1, 1); + + long first = generator.nextId(); + long second = generator.nextId(); + + assertTrue(first > 0); + assertTrue(second > first); + + Set ids = new HashSet<>(); + for (int i = 0; i < 200; i++) { + ids.add(generator.nextId()); + } + assertEquals(200, ids.size()); + } + + @Test + @DisplayName("Given default generator when generating ids then it returns positive ids") + void givenDefaultGeneratorWhenGeneratingThenReturnPositiveIds() { + SnowflakeId generator = SnowflakeId.getInstance(); + + long id = generator.nextId(); + + assertTrue(id > 0); + } + + @Test + @DisplayName("Given out of range worker or datacenter when creating generator then illegal argument is thrown") + void givenOutOfRangeWorkerOrDatacenterWhenCreatingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(-1, 0)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(32, 0)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(0, -1)); + assertThrows(IllegalArgumentException.class, () -> SnowflakeId.getInstance(0, 32)); + } +} + diff --git a/core/src/test/java/com/euonia/core/ULIDTest.java b/core/src/test/java/com/euonia/core/ULIDTest.java new file mode 100644 index 0000000..2424a92 --- /dev/null +++ b/core/src/test/java/com/euonia/core/ULIDTest.java @@ -0,0 +1,40 @@ +package com.euonia.core; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ULID") +class ULIDTest { + + private static final String CROCKFORD_BASE32 = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + + @Test + @DisplayName("Given generated ULID when checking format then it has 32 chars and valid alphabet") + void givenGeneratedUlidWhenCheckingFormatThenLengthAndAlphabetAreValid() { + String ulid = ULID.generate(); + + assertNotNull(ulid); + assertEquals(32, ulid.length()); + for (char c : ulid.toCharArray()) { + assertTrue(CROCKFORD_BASE32.indexOf(c) >= 0, () -> "Unexpected character: " + c); + } + } + + @Test + @DisplayName("Given multiple generated ULIDs when collecting then ids are unique") + void givenMultipleGeneratedUlidsWhenCollectingThenIdsAreUnique() { + Set values = new HashSet<>(); + + for (int i = 0; i < 200; i++) { + values.add(ULID.generate()); + } + + assertEquals(200, values.size()); + } +} + diff --git a/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java b/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java new file mode 100644 index 0000000..6a9b238 --- /dev/null +++ b/core/src/test/java/com/euonia/http/HttpDerivedExceptionsTest.java @@ -0,0 +1,61 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.function.Function; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("HTTP derived exceptions") +class HttpDerivedExceptionsTest { + + @ParameterizedTest(name = "{0} one-arg constructor sets status {1}") + @MethodSource("exceptionFactories") + void givenMessageWhenConstructingDerivedExceptionThenStatusAndMessageMatch( + String name, + int expectedStatus, + Function singleArgFactory, + ExceptionBiFactory biFactory + ) { + HttpStatusException exception = singleArgFactory.apply("msg"); + + assertEquals(expectedStatus, exception.getStatusCode()); + assertEquals("msg", exception.getMessage()); + assertNull(exception.getCause()); + assertTrue(exception.getClass().getSimpleName().contains(name)); + + IllegalArgumentException cause = new IllegalArgumentException("cause"); + HttpStatusException withCause = biFactory.create("msg2", cause); + + assertEquals(expectedStatus, withCause.getStatusCode()); + assertEquals("msg2", withCause.getMessage()); + assertSame(cause, withCause.getCause()); + } + + private static Stream exceptionFactories() { + return Stream.of( + Arguments.of("BadRequest", 400, (Function) BadRequestException::new, (ExceptionBiFactory) BadRequestException::new), + Arguments.of("Forbidden", 403, (Function) ForbiddenException::new, (ExceptionBiFactory) ForbiddenException::new), + Arguments.of("ResourceNotFound", 404, (Function) ResourceNotFoundException::new, (ExceptionBiFactory) ResourceNotFoundException::new), + Arguments.of("MethodNotAllowed", 405, (Function) MethodNotAllowedException::new, (ExceptionBiFactory) MethodNotAllowedException::new), + Arguments.of("RequestTimeout", 408, (Function) RequestTimeoutException::new, (ExceptionBiFactory) RequestTimeoutException::new), + Arguments.of("Conflict", 409, (Function) ConflictException::new, (ExceptionBiFactory) ConflictException::new), + Arguments.of("UpgradeRequired", 426, (Function) UpgradeRequiredException::new, (ExceptionBiFactory) UpgradeRequiredException::new), + Arguments.of("TooManyRequests", 429, (Function) TooManyRequestsException::new, (ExceptionBiFactory) TooManyRequestsException::new), + Arguments.of("InternalServerError", 500, (Function) InternalServerErrorException::new, (ExceptionBiFactory) InternalServerErrorException::new), + Arguments.of("BadGateway", 502, (Function) BadGatewayException::new, (ExceptionBiFactory) BadGatewayException::new), + Arguments.of("ServiceUnavailable", 503, (Function) ServiceUnavailableException::new, (ExceptionBiFactory) ServiceUnavailableException::new), + Arguments.of("GatewayTimeout", 504, (Function) GatewayTimeoutException::new, (ExceptionBiFactory) GatewayTimeoutException::new) + ); + } + + @FunctionalInterface + private interface ExceptionBiFactory { + HttpStatusException create(String message, Throwable cause); + } +} + diff --git a/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java b/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java new file mode 100644 index 0000000..312f228 --- /dev/null +++ b/core/src/test/java/com/euonia/http/HttpStatusExceptionTest.java @@ -0,0 +1,33 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("HttpStatusException") +class HttpStatusExceptionTest { + + @Test + @DisplayName("Given status and message when constructing then properties are preserved") + void givenStatusAndMessageWhenConstructingThenPropertiesArePreserved() { + HttpStatusException exception = new HttpStatusException(418, "teapot"); + + assertEquals(418, exception.getStatusCode()); + assertEquals("teapot", exception.getMessage()); + assertNull(exception.getCause()); + } + + @Test + @DisplayName("Given status message and cause when constructing then properties include cause") + void givenStatusMessageAndCauseWhenConstructingThenPropertiesIncludeCause() { + IllegalStateException cause = new IllegalStateException("broken"); + + HttpStatusException exception = new HttpStatusException(500, "server error", cause); + + assertEquals(500, exception.getStatusCode()); + assertEquals("server error", exception.getMessage()); + assertSame(cause, exception.getCause()); + } +} + diff --git a/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java b/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java new file mode 100644 index 0000000..f43782f --- /dev/null +++ b/core/src/test/java/com/euonia/http/ResponseHttpStatusCodeTest.java @@ -0,0 +1,40 @@ +package com.euonia.http; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("ResponseHttpStatusCode") +class ResponseHttpStatusCodeTest { + + @ResponseHttpStatusCode(204) + private static class AnnotatedType { + } + + @Test + @DisplayName("Given annotation definition when inspecting metadata then target is type and retention is runtime") + void givenAnnotationDefinitionWhenInspectingThenTargetAndRetentionMatch() { + Target target = ResponseHttpStatusCode.class.getAnnotation(Target.class); + Retention retention = ResponseHttpStatusCode.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.TYPE}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given annotated class when reading annotation then status code value is available") + void givenAnnotatedClassWhenReadingAnnotationThenStatusCodeValueIsAvailable() { + ResponseHttpStatusCode annotation = AnnotatedType.class.getAnnotation(ResponseHttpStatusCode.class); + + assertNotNull(annotation); + assertEquals(204, annotation.value()); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/DisplayNameTest.java b/core/src/test/java/com/euonia/reflection/DisplayNameTest.java new file mode 100644 index 0000000..a099e88 --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/DisplayNameTest.java @@ -0,0 +1,42 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("com.euonia.reflection.DisplayName") +class DisplayNameTest { + + private static class Sample { + @com.euonia.reflection.DisplayName("User Name") + private String username; + } + + @Test + @DisplayName("Given annotation definition when inspecting metadata then target is field and retention is runtime") + void givenAnnotationDefinitionWhenInspectingThenTargetAndRetentionMatch() { + Target target = com.euonia.reflection.DisplayName.class.getAnnotation(Target.class); + Retention retention = com.euonia.reflection.DisplayName.class.getAnnotation(Retention.class); + + assertNotNull(target); + assertArrayEquals(new ElementType[]{ElementType.FIELD}, target.value()); + assertNotNull(retention); + assertEquals(java.lang.annotation.RetentionPolicy.RUNTIME, retention.value()); + } + + @Test + @DisplayName("Given field with annotation when reading then display name value is available") + void givenAnnotatedFieldWhenReadingThenDisplayNameValueIsAvailable() throws NoSuchFieldException { + var field = Sample.class.getDeclaredField("username"); + com.euonia.reflection.DisplayName annotation = field.getAnnotation(com.euonia.reflection.DisplayName.class); + + assertNotNull(annotation); + assertEquals("User Name", annotation.value()); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/GenericTypeTest.java b/core/src/test/java/com/euonia/reflection/GenericTypeTest.java new file mode 100644 index 0000000..f11cc5f --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/GenericTypeTest.java @@ -0,0 +1,50 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Type; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("GenericType") +class GenericTypeTest { + + @Test + @DisplayName("Given anonymous generic subclass when creating then generic type argument is captured") + void givenAnonymousGenericSubclassWhenCreatingThenTypeIsCaptured() { + GenericType> typeRef = new GenericType<>() { + }; + + Type type = typeRef.getType(); + + assertEquals("java.util.List", type.getTypeName()); + assertTrue(typeRef.toString().contains("java.util.List")); + } + + @Test + @DisplayName("Given forType factory when creating wrappers then equals and hashCode depend on wrapped type") + void givenForTypeFactoryWhenCreatingThenEqualsAndHashCodeDependOnType() { + Type type = String.class; + + GenericType left = GenericType.forType(type); + GenericType right = GenericType.forType(type); + GenericType other = GenericType.forType(Integer.class); + + assertEquals(left, right); + assertEquals(left.hashCode(), right.hashCode()); + assertNotEquals(left, other); + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + @Test + @DisplayName("Given raw GenericType subclass when instantiating then illegal argument is thrown") + void givenRawSubclassWhenInstantiatingThenThrowIllegalArgument() { + class RawGenericType extends GenericType { + } + + assertThrows(IllegalArgumentException.class, RawGenericType::new); + } +} + diff --git a/core/src/test/java/com/euonia/reflection/TypeHelperTest.java b/core/src/test/java/com/euonia/reflection/TypeHelperTest.java new file mode 100644 index 0000000..7c5a7f0 --- /dev/null +++ b/core/src/test/java/com/euonia/reflection/TypeHelperTest.java @@ -0,0 +1,111 @@ +package com.euonia.reflection; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("TypeHelper") +class TypeHelperTest { + + private enum SampleEnum { + FIRST, + SECOND + } + + @Test + @DisplayName("Given null desired type when coercing then illegal argument is thrown") + void givenNullDesiredTypeWhenCoercingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(null, String.class, "x")); + } + + @Test + @DisplayName("Given null value and primitive target when coercing then primitive default is returned") + void givenNullValueAndPrimitiveTargetWhenCoercingThenReturnPrimitiveDefault() { + assertEquals(0, TypeHelper.coerceValue(int.class, null)); + assertEquals(false, TypeHelper.coerceValue(boolean.class, null)); + assertEquals('\0', TypeHelper.coerceValue(char.class, null)); + } + + @Test + @DisplayName("Given enum target when coercing from name and ordinal then enum values are returned") + void givenEnumTargetWhenCoercingThenReturnEnumValues() { + assertEquals(SampleEnum.SECOND, TypeHelper.coerceValue(SampleEnum.class, "SECOND")); + assertEquals(SampleEnum.FIRST, TypeHelper.coerceValue(SampleEnum.class, 0)); + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(SampleEnum.class, 5)); + } + + @Test + @DisplayName("Given boolean target when coercing from number and text then boolean values are returned") + void givenBooleanTargetWhenCoercingThenReturnBooleanValues() { + assertEquals(true, TypeHelper.coerceValue(Boolean.class, 1)); + assertEquals(false, TypeHelper.coerceValue(Boolean.class, 0)); + assertEquals(true, TypeHelper.coerceValue(Boolean.class, "yes")); + assertEquals(false, TypeHelper.coerceValue(Boolean.class, "no")); + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(Boolean.class, "maybe")); + } + + @Test + @DisplayName("Given numeric target when coercing from text then numeric value is returned") + void givenNumericTargetWhenCoercingFromTextThenReturnNumericValue() { + assertEquals(42, TypeHelper.coerceValue(Integer.class, "42")); + assertEquals(0L, TypeHelper.coerceValue(Long.class, "")); + assertEquals(3.5d, TypeHelper.coerceValue(Double.class, "3.5")); + } + + @Test + @DisplayName("Given UUID and Character targets when coercing then values are converted") + void givenUuidAndCharacterTargetsWhenCoercingThenValuesAreConverted() { + UUID uuid = UUID.randomUUID(); + + assertEquals(uuid, TypeHelper.coerceValue(UUID.class, uuid.toString())); + assertNull(TypeHelper.coerceValue(UUID.class, "")); + assertEquals('A', TypeHelper.coerceValue(Character.class, "ABC")); + assertEquals('\0', TypeHelper.coerceValue(Character.class, "")); + } + + @Test + @DisplayName("Given date time targets when coercing then temporal values are converted") + void givenDateTimeTargetsWhenCoercingThenTemporalValuesAreConverted() { + Instant now = Instant.now(); + + Date date = TypeHelper.coerceValue(Date.class, now.toEpochMilli()); + Instant parsedInstant = TypeHelper.coerceValue(Instant.class, now.toString()); + LocalDate parsedDate = TypeHelper.coerceValue(LocalDate.class, "2026-06-07"); + + assertEquals(now.toEpochMilli(), date.getTime()); + assertEquals(now, parsedInstant); + assertEquals(LocalDate.of(2026, 6, 7), parsedDate); + } + + @Test + @DisplayName("Given array and collection targets when coercing iterable then converted collections are returned") + void givenArrayAndCollectionTargetsWhenCoercingIterableThenReturnConvertedCollections() { + List values = Arrays.asList("1", "2", "3"); + + int[] ints = TypeHelper.coerceValue(int[].class, values); + Object list = TypeHelper.coerceValue(List.class, new String[]{"a", "b"}); + Object concreteList = TypeHelper.coerceValue(ArrayList.class, new String[]{"x", "y"}); + + assertArrayEquals(new int[]{1, 2, 3}, ints); + assertInstanceOf(List.class, list); + assertEquals(List.of("a", "b"), list); + assertInstanceOf(ArrayList.class, concreteList); + assertEquals(List.of("x", "y"), concreteList); + } + + @Test + @DisplayName("Given incompatible conversion when coercing then illegal argument is thrown") + void givenIncompatibleConversionWhenCoercingThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> TypeHelper.coerceValue(Integer.class, new Object())); + } +} + diff --git a/core/src/test/java/com/euonia/security/AccountExceptionTest.java b/core/src/test/java/com/euonia/security/AccountExceptionTest.java new file mode 100644 index 0000000..cc59986 --- /dev/null +++ b/core/src/test/java/com/euonia/security/AccountExceptionTest.java @@ -0,0 +1,55 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("AccountException") +class AccountExceptionTest { + + private static class TestAccountException extends AccountException { + TestAccountException(Object identity) { + super(identity); + } + + TestAccountException(Object identity, String message) { + super(identity, message); + } + + TestAccountException(Object identity, String message, Throwable cause) { + super(identity, message, cause); + } + } + + @Test + @DisplayName("Given identity and optional message cause when constructing then properties are preserved") + void givenConstructorsWhenCreatingThenPropertiesArePreserved() { + Throwable cause = new IllegalStateException("boom"); + + TestAccountException oneArg = new TestAccountException("user1"); + TestAccountException twoArg = new TestAccountException("user2", "msg"); + TestAccountException threeArg = new TestAccountException("user3", "msg2", cause); + + assertEquals("user1", oneArg.getIdentity()); + assertNull(oneArg.getMessage()); + assertEquals("user2", twoArg.getIdentity()); + assertEquals("msg", twoArg.getMessage()); + assertEquals("user3", threeArg.getIdentity()); + assertEquals("msg2", threeArg.getMessage()); + assertSame(cause, threeArg.getCause()); + } + + @Test + @DisplayName("Given details map when setting values then unsupported operation is thrown by immutable backing map") + void givenDetailsWhenSettingThenThrowUnsupportedOperation() { + TestAccountException exception = new TestAccountException("user"); + + assertNotNull(exception.getDetails()); + assertTrue(exception.getDetails().isEmpty()); + assertNull(exception.get("missing")); + assertThrows(UnsupportedOperationException.class, () -> exception.set("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> exception.with("k", "v")); + } +} + diff --git a/core/src/test/java/com/euonia/security/AuthExceptionsTest.java b/core/src/test/java/com/euonia/security/AuthExceptionsTest.java new file mode 100644 index 0000000..05e93e0 --- /dev/null +++ b/core/src/test/java/com/euonia/security/AuthExceptionsTest.java @@ -0,0 +1,39 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Authentication and authorization exceptions") +class AuthExceptionsTest { + + @Test + @DisplayName("Given message and cause when constructing AuthenticationException then properties are preserved") + void givenMessageAndCauseWhenConstructingAuthenticationExceptionThenPropertiesArePreserved() { + Throwable cause = new RuntimeException("cause"); + + AuthenticationException oneArg = new AuthenticationException("auth failed"); + AuthenticationException twoArg = new AuthenticationException("auth failed", cause); + + assertEquals("auth failed", oneArg.getMessage()); + assertNull(oneArg.getCause()); + assertEquals("auth failed", twoArg.getMessage()); + assertSame(cause, twoArg.getCause()); + } + + @Test + @DisplayName("Given message and cause when constructing UnauthorizedAccessException then properties are preserved") + void givenMessageAndCauseWhenConstructingUnauthorizedAccessExceptionThenPropertiesArePreserved() { + Throwable cause = new RuntimeException("cause"); + + UnauthorizedAccessException oneArg = new UnauthorizedAccessException("forbidden"); + UnauthorizedAccessException twoArg = new UnauthorizedAccessException("forbidden", cause); + + assertEquals("forbidden", oneArg.getMessage()); + assertNull(oneArg.getCause()); + assertEquals("forbidden", twoArg.getMessage()); + assertSame(cause, twoArg.getCause()); + } +} + diff --git a/core/src/test/java/com/euonia/security/CredentialExceptionTest.java b/core/src/test/java/com/euonia/security/CredentialExceptionTest.java new file mode 100644 index 0000000..d159007 --- /dev/null +++ b/core/src/test/java/com/euonia/security/CredentialExceptionTest.java @@ -0,0 +1,55 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("CredentialException") +class CredentialExceptionTest { + + private static class TestCredentialException extends CredentialException { + TestCredentialException(Object credential) { + super(credential); + } + + TestCredentialException(Object credential, String message) { + super(credential, message); + } + + TestCredentialException(Object credential, String message, Throwable cause) { + super(credential, message, cause); + } + } + + @Test + @DisplayName("Given credential and optional message cause when constructing then properties are preserved") + void givenConstructorsWhenCreatingThenPropertiesArePreserved() { + Throwable cause = new IllegalArgumentException("invalid"); + + TestCredentialException oneArg = new TestCredentialException("cred1"); + TestCredentialException twoArg = new TestCredentialException("cred2", "msg"); + TestCredentialException threeArg = new TestCredentialException("cred3", "msg2", cause); + + assertEquals("cred1", oneArg.getCredential()); + assertNull(oneArg.getMessage()); + assertEquals("cred2", twoArg.getCredential()); + assertEquals("msg", twoArg.getMessage()); + assertEquals("cred3", threeArg.getCredential()); + assertEquals("msg2", threeArg.getMessage()); + assertSame(cause, threeArg.getCause()); + } + + @Test + @DisplayName("Given details map when setting values then unsupported operation is thrown by immutable backing map") + void givenDetailsWhenSettingThenThrowUnsupportedOperation() { + TestCredentialException exception = new TestCredentialException("credential"); + + assertNotNull(exception.getDetails()); + assertTrue(exception.getDetails().isEmpty()); + assertNull(exception.get("missing")); + assertThrows(UnsupportedOperationException.class, () -> exception.set("k", "v")); + assertThrows(UnsupportedOperationException.class, () -> exception.with("k", "v")); + } +} + diff --git a/core/src/test/java/com/euonia/security/UserClaimTypesTest.java b/core/src/test/java/com/euonia/security/UserClaimTypesTest.java new file mode 100644 index 0000000..00f802b --- /dev/null +++ b/core/src/test/java/com/euonia/security/UserClaimTypesTest.java @@ -0,0 +1,49 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserClaimTypes") +class UserClaimTypesTest { + + @Test + @DisplayName("Given claim constants when reading representative values then they match expected OIDC names") + void givenClaimConstantsWhenReadingThenRepresentativeValuesMatch() { + assertEquals("sub", UserClaimTypes.SUBJECT); + assertEquals("name", UserClaimTypes.NAME); + assertEquals("email", UserClaimTypes.EMAIL); + assertEquals("role", UserClaimTypes.ROLE); + assertEquals("tenant", UserClaimTypes.TENANT); + assertEquals("scheme", UserClaimTypes.SCHEME); + } + + @Test + @DisplayName("Given claim constants when reflecting then all are public static final and values are unique") + void givenClaimConstantsWhenReflectingThenModifiersAndUniquenessMatch() throws IllegalAccessException { + Field[] fields = UserClaimTypes.class.getDeclaredFields(); + Set values = new HashSet<>(); + int constantCount = 0; + + for (Field field : fields) { + if (field.getType() == String.class) { + int modifiers = field.getModifiers(); + assertTrue(Modifier.isPublic(modifiers)); + assertTrue(Modifier.isStatic(modifiers)); + assertTrue(Modifier.isFinal(modifiers)); + String value = (String) field.get(null); + assertTrue(values.add(value), () -> "Duplicate claim value: " + value); + constantCount++; + } + } + + assertTrue(constantCount >= 40); + } +} + diff --git a/core/src/test/java/com/euonia/security/UserPrincipalTest.java b/core/src/test/java/com/euonia/security/UserPrincipalTest.java new file mode 100644 index 0000000..344d99a --- /dev/null +++ b/core/src/test/java/com/euonia/security/UserPrincipalTest.java @@ -0,0 +1,81 @@ +package com.euonia.security; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.security.auth.Subject; +import java.security.Principal; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("UserPrincipal") +class UserPrincipalTest { + + @Test + @DisplayName("Given subject with principals when querying principal info then name authentication and roles are resolved") + void givenSubjectWithPrincipalsWhenQueryingThenNameAuthAndRolesResolve() { + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "alice"); + subject.getPrincipals().add((Principal) () -> "admin"); + + UserPrincipal userPrincipal = new UserPrincipal(subject); + + assertSame(subject, userPrincipal.getSubject()); + assertEquals("alice", userPrincipal.getName()); + assertTrue(userPrincipal.isAuthenticated()); + assertTrue(userPrincipal.hasRole("admin")); + assertTrue(userPrincipal.isInRoles("guest", "admin")); + assertNull(userPrincipal.getClaim(UserClaimTypes.EMAIL)); + } + + @Test + @DisplayName("Given null or empty subject when querying then unauthenticated behavior is returned") + void givenNullOrEmptySubjectWhenQueryingThenUnauthenticatedBehaviorReturned() { + UserPrincipal withNullSubject = new UserPrincipal(null); + UserPrincipal withEmptySubject = new UserPrincipal(new Subject()); + + assertFalse(withNullSubject.isAuthenticated()); + assertFalse(withNullSubject.hasRole("admin")); + assertFalse(withNullSubject.isInRoles("admin")); + assertThrows(NullPointerException.class, withNullSubject::getName); + + assertFalse(withEmptySubject.isAuthenticated()); + assertFalse(withEmptySubject.hasRole("admin")); + assertFalse(withEmptySubject.isInRoles("admin")); + assertNull(withEmptySubject.getName()); + } + + @Test + @DisplayName("Given unauthenticated or unauthorized user when ensuring access then security exceptions are thrown") + void givenUnauthenticatedOrUnauthorizedUserWhenEnsuringAccessThenThrow() { + UserPrincipal unauthenticated = new UserPrincipal(new Subject()); + + AuthenticationException authException = assertThrows(AuthenticationException.class, unauthenticated::ensureAuthenticated); + assertEquals("User is not authenticated", authException.getMessage()); + + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "user"); + UserPrincipal authenticated = new UserPrincipal(subject); + + UnauthorizedAccessException roleException = assertThrows(UnauthorizedAccessException.class, + () -> authenticated.ensureHasRole("admin")); + assertEquals("User does not have required role: admin", roleException.getMessage()); + + UnauthorizedAccessException rolesException = assertThrows(UnauthorizedAccessException.class, + () -> authenticated.ensureInRoles("admin", "ops")); + assertEquals("User does not have any of the required roles: admin, ops", rolesException.getMessage()); + } + + @Test + @DisplayName("Given authorized user when ensuring role membership then no exception is thrown") + void givenAuthorizedUserWhenEnsuringRoleMembershipThenNoExceptionThrown() { + Subject subject = new Subject(); + subject.getPrincipals().add((Principal) () -> "dev"); + UserPrincipal userPrincipal = new UserPrincipal(subject); + + assertDoesNotThrow(userPrincipal::ensureAuthenticated); + assertDoesNotThrow(() -> userPrincipal.ensureHasRole("dev")); + assertDoesNotThrow(() -> userPrincipal.ensureInRoles("ops", "dev")); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/DecetTest.java b/core/src/test/java/com/euonia/tuple/DecetTest.java new file mode 100644 index 0000000..e046771 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/DecetTest.java @@ -0,0 +1,37 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Decet") +class DecetTest { + + @Test + @DisplayName("Given valid inputs when creating Decet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingDecetThenFactoriesBehaveAsExpected() { + Decet tuple = + Decet.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10); + + assertEquals(10, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10), tuple.values()); + assertEquals(10, Decet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}).value10()); + assertEquals(6, Decet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)).value6()); + assertNull(Decet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Decet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingDecetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Decet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Decet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9})); + assertThrows(IllegalArgumentException.class, () -> Decet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Decet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Decet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/DuetTest.java b/core/src/test/java/com/euonia/tuple/DuetTest.java new file mode 100644 index 0000000..35ff4a0 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/DuetTest.java @@ -0,0 +1,37 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Duet") +class DuetTest { + + @Test + @DisplayName("Given valid inputs when creating Duet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingDuetThenFactoriesBehaveAsExpected() { + Duet duet = Duet.of(1, "a"); + + assertEquals(2, duet.size()); + assertEquals(List.of(1, "a"), duet.values()); + assertEquals(1, Duet.from(new Integer[]{1, 2}).value1()); + assertEquals(2, Duet.from(List.of(1, 2)).value2()); + assertNull(Duet.empty().value1()); + assertNull(Duet.empty().value2()); + } + + @Test + @DisplayName("Given invalid inputs when creating Duet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingDuetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Duet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Duet.from(new Integer[]{1})); + assertThrows(IllegalArgumentException.class, () -> Duet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Duet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Duet.from(List.of(1))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/NonetTest.java b/core/src/test/java/com/euonia/tuple/NonetTest.java new file mode 100644 index 0000000..4f82334 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/NonetTest.java @@ -0,0 +1,37 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Nonet") +class NonetTest { + + @Test + @DisplayName("Given valid inputs when creating Nonet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingNonetThenFactoriesBehaveAsExpected() { + Nonet tuple = + Nonet.of(1, 2, 3, 4, 5, 6, 7, 8, 9); + + assertEquals(9, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9), tuple.values()); + assertEquals(9, Nonet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8, 9}).value9()); + assertEquals(5, Nonet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8, 9)).value5()); + assertNull(Nonet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Nonet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingNonetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Nonet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8})); + assertThrows(IllegalArgumentException.class, () -> Nonet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Nonet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/OctetTest.java b/core/src/test/java/com/euonia/tuple/OctetTest.java new file mode 100644 index 0000000..015165a --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/OctetTest.java @@ -0,0 +1,36 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Octet") +class OctetTest { + + @Test + @DisplayName("Given valid inputs when creating Octet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingOctetThenFactoriesBehaveAsExpected() { + Octet tuple = Octet.of(1, 2, 3, 4, 5, 6, 7, 8); + + assertEquals(8, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7, 8), tuple.values()); + assertEquals(8, Octet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7, 8}).value8()); + assertEquals(4, Octet.from(List.of(1, 2, 3, 4, 5, 6, 7, 8)).value4()); + assertNull(Octet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Octet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingOctetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Octet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Octet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7})); + assertThrows(IllegalArgumentException.class, () -> Octet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Octet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Octet.from(List.of(1, 2, 3, 4, 5, 6, 7))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/QuartetTest.java b/core/src/test/java/com/euonia/tuple/QuartetTest.java new file mode 100644 index 0000000..d2aeabf --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/QuartetTest.java @@ -0,0 +1,36 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Quartet") +class QuartetTest { + + @Test + @DisplayName("Given valid inputs when creating Quartet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingQuartetThenFactoriesBehaveAsExpected() { + Quartet tuple = Quartet.of(1, 2, 3, 4); + + assertEquals(4, tuple.size()); + assertEquals(List.of(1, 2, 3, 4), tuple.values()); + assertEquals(3, Quartet.from(new Integer[]{1, 2, 3, 4}).value3()); + assertEquals(4, Quartet.from(List.of(1, 2, 3, 4)).value4()); + assertNull(Quartet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Quartet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingQuartetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Quartet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(new Integer[]{1, 2, 3})); + assertThrows(IllegalArgumentException.class, () -> Quartet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Quartet.from(List.of(1, 2, 3))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/QuintetTest.java b/core/src/test/java/com/euonia/tuple/QuintetTest.java new file mode 100644 index 0000000..4d47ed8 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/QuintetTest.java @@ -0,0 +1,36 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Quintet") +class QuintetTest { + + @Test + @DisplayName("Given valid inputs when creating Quintet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingQuintetThenFactoriesBehaveAsExpected() { + Quintet tuple = Quintet.of(1, 2, 3, 4, 5); + + assertEquals(5, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5), tuple.values()); + assertEquals(5, Quintet.from(new Integer[]{1, 2, 3, 4, 5}).value5()); + assertEquals(1, Quintet.from(List.of(1, 2, 3, 4, 5)).value1()); + assertNull(Quintet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Quintet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingQuintetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Quintet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(new Integer[]{1, 2, 3, 4})); + assertThrows(IllegalArgumentException.class, () -> Quintet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Quintet.from(List.of(1, 2, 3, 4))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SeptetTest.java b/core/src/test/java/com/euonia/tuple/SeptetTest.java new file mode 100644 index 0000000..1685300 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SeptetTest.java @@ -0,0 +1,36 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Septet") +class SeptetTest { + + @Test + @DisplayName("Given valid inputs when creating Septet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingSeptetThenFactoriesBehaveAsExpected() { + Septet tuple = Septet.of(1, 2, 3, 4, 5, 6, 7); + + assertEquals(7, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6, 7), tuple.values()); + assertEquals(7, Septet.from(new Integer[]{1, 2, 3, 4, 5, 6, 7}).value7()); + assertEquals(3, Septet.from(List.of(1, 2, 3, 4, 5, 6, 7)).value3()); + assertNull(Septet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Septet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSeptetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Septet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Septet.from(new Integer[]{1, 2, 3, 4, 5, 6})); + assertThrows(IllegalArgumentException.class, () -> Septet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Septet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Septet.from(List.of(1, 2, 3, 4, 5, 6))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SextetTest.java b/core/src/test/java/com/euonia/tuple/SextetTest.java new file mode 100644 index 0000000..4639cc3 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SextetTest.java @@ -0,0 +1,36 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Sextet") +class SextetTest { + + @Test + @DisplayName("Given valid inputs when creating Sextet then values and empty factory behave as expected") + void givenValidInputsWhenCreatingSextetThenFactoriesBehaveAsExpected() { + Sextet tuple = Sextet.of(1, 2, 3, 4, 5, 6); + + assertEquals(6, tuple.size()); + assertEquals(List.of(1, 2, 3, 4, 5, 6), tuple.values()); + assertEquals(6, Sextet.from(new Integer[]{1, 2, 3, 4, 5, 6}).value6()); + assertEquals(2, Sextet.from(List.of(1, 2, 3, 4, 5, 6)).value2()); + assertNull(Sextet.empty().value1()); + } + + @Test + @DisplayName("Given invalid inputs when creating Sextet from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSextetFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Sextet.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(new Integer[]{1, 2, 3, 4, 5})); + assertThrows(IllegalArgumentException.class, () -> Sextet.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Sextet.from(List.of(1, 2, 3, 4, 5))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/SoloTest.java b/core/src/test/java/com/euonia/tuple/SoloTest.java new file mode 100644 index 0000000..9290114 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/SoloTest.java @@ -0,0 +1,43 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Solo") +class SoloTest { + + @Test + @DisplayName("Given valid inputs when creating Solo then value is preserved") + void givenValidInputsWhenCreatingSoloThenValueIsPreserved() { + assertEquals("x", Solo.of("x").value()); + assertEquals("x", Solo.from(new String[]{"x"}).value()); + assertEquals("x", Solo.from(List.of("x")).value()); + } + + @Test + @DisplayName("Given solo instance when querying size values and contains then expected behavior is observed") + void givenSoloWhenQueryingCoreMethodsThenReturnExpectedResults() { + Solo solo = Solo.of("v"); + + assertEquals(1, solo.size()); + assertEquals(List.of("v"), solo.values()); + assertTrue(solo.contains("v")); + assertFalse(solo.contains("other")); + } + + @Test + @DisplayName("Given invalid inputs when creating Solo from array or list then illegal argument is thrown") + void givenInvalidInputsWhenCreatingSoloFromArrayOrListThenThrowIllegalArgument() { + assertThrows(IllegalArgumentException.class, () -> Solo.from((String[]) null)); + assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{})); + assertThrows(IllegalArgumentException.class, () -> Solo.from(new String[]{"a", "b"})); + assertThrows(IllegalArgumentException.class, () -> Solo.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Solo.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Solo.from(List.of("a", "b"))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/TrioTest.java b/core/src/test/java/com/euonia/tuple/TrioTest.java new file mode 100644 index 0000000..56cb699 --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/TrioTest.java @@ -0,0 +1,43 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Trio") +class TrioTest { + + @Test + @DisplayName("Given valid inputs when creating Trio then factories value lookup and empty factory behave as expected") + void givenValidInputsWhenCreatingTrioThenFactoriesAndLookupBehaveAsExpected() { + Trio trio = Trio.of(1, 2, 3); + + assertEquals(3, trio.size()); + assertEquals(1, trio.value(0)); + assertEquals(2, trio.value(1)); + assertEquals(3, trio.value(2)); + assertTrue(trio.contains(2)); + assertEquals(List.of(1, 2, 3), trio.values()); + assertNull(Trio.empty().value1()); + assertEquals(3, Trio.from(new Integer[]{1, 2, 3}).value3()); + assertEquals(2, Trio.from(List.of(1, 2, 3)).value2()); + } + + @Test + @DisplayName("Given invalid index or inputs when reading or creating Trio then appropriate exceptions are thrown") + void givenInvalidIndexOrInputsWhenReadingOrCreatingTrioThenThrowExpectedExceptions() { + Trio trio = Trio.of(1, 2, 3); + + assertThrows(IndexOutOfBoundsException.class, () -> trio.value(3)); + assertThrows(IllegalArgumentException.class, () -> Trio.from((Integer[]) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.from(new Integer[]{})); + assertThrows(IllegalArgumentException.class, () -> Trio.from(new Integer[]{1, 2})); + assertThrows(IllegalArgumentException.class, () -> Trio.from((List) null)); + assertThrows(IllegalArgumentException.class, () -> Trio.from(List.of())); + assertThrows(IllegalArgumentException.class, () -> Trio.from(List.of(1, 2))); + } +} + diff --git a/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java new file mode 100644 index 0000000..bd8e83d --- /dev/null +++ b/core/src/test/java/com/euonia/tuple/TupleDefaultMethodsTest.java @@ -0,0 +1,57 @@ +package com.euonia.tuple; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.DisplayName; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +@DisplayName("Tuple default methods") +class TupleDefaultMethodsTest { + + @Test + @DisplayName("Given tuple values when using common default methods then expected results are returned") + void givenTupleValuesWhenUsingCommonDefaultsThenReturnExpectedResults() { + Tuple tuple = Trio.of(1, 2, 1); + + assertEquals(3, tuple.size()); + assertEquals(1, tuple.value(0)); + assertTrue(tuple.contains(2)); + assertTrue(tuple.containsAll(List.of(1, 2))); + assertTrue(tuple.containsAll(1, 2)); + assertFalse(tuple.containsAll(1, 3)); + assertTrue(tuple.containsAny(List.of(9, 2))); + assertTrue(tuple.containsAny(9, 1)); + assertFalse(tuple.containsAny(9, 8)); + assertEquals(0, tuple.indexOf(1)); + assertEquals(2, tuple.lastIndexOf(1)); + assertArrayEquals(new Object[]{1, 2, 1}, tuple.toArray()); + assertArrayEquals(new Integer[]{1, 2, 1}, tuple.toArray(new Integer[0])); + assertEquals(List.of(1, 2, 1), tuple.toList()); + } + + @Test + @DisplayName("Given duplicated elements when comparing ignoring order then multiplicity and size are respected") + void givenDuplicatedElementsWhenComparingIgnoreOrderThenRespectMultiplicityAndSize() { + Tuple left = Quartet.of(1, 2, 2, 3); + Tuple sameElementsDifferentOrder = Quartet.of(2, 3, 1, 2); + Tuple missingDuplicate = Quartet.of(1, 2, 3, 3); + Tuple differentSize = Trio.of(1, 2, 2); + + assertTrue(left.equalsIgnoreOrder(sameElementsDifferentOrder)); + assertFalse(left.equalsIgnoreOrder(missingDuplicate)); + assertFalse(left.equalsIgnoreOrder(differentSize)); + assertFalse(left.equalsIgnoreOrder(null)); + } + + @Test + @DisplayName("Given invalid index when reading tuple value then index out of bounds is thrown") + void givenInvalidIndexWhenReadingValueThenThrowIndexOutOfBounds() { + Tuple tuple = Duet.of(1, 2); + + assertThrows(IndexOutOfBoundsException.class, () -> tuple.value(-1)); + assertThrows(IndexOutOfBoundsException.class, () -> tuple.value(2)); + } +} + diff --git a/domain/pom.xml b/domain/pom.xml new file mode 100644 index 0000000..e6d65d0 --- /dev/null +++ b/domain/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + + com.euonia + parent + ${revision} + + + domain + + + + com.euonia + core + ${revision} + + + + diff --git a/domain/src/main/java/com/euonia/domain/Aggregate.java b/domain/src/main/java/com/euonia/domain/Aggregate.java new file mode 100644 index 0000000..d4bb2c6 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/Aggregate.java @@ -0,0 +1,4 @@ +package com.euonia.domain; + +public interface Aggregate> extends Entity { +} diff --git a/domain/src/main/java/com/euonia/domain/AggregateBase.java b/domain/src/main/java/com/euonia/domain/AggregateBase.java new file mode 100644 index 0000000..d9c7147 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/AggregateBase.java @@ -0,0 +1,28 @@ +package com.euonia.domain; + +import com.euonia.domain.event.DomainEventBase; + +import java.util.ArrayList; +import java.util.List; + +public abstract class AggregateBase> extends EntityBase implements Aggregate { + private final List events = new ArrayList<>(); + + public List getEvents() { + return List.copyOf(events); + } + + protected void raiseEvent(E event) { + events.add(event); + } + + protected void clearEvents() { + events.clear(); + } + + public void attachEvents() { + for (DomainEventBase event : events) { + event.attach(this); + } + } +} diff --git a/domain/src/main/java/com/euonia/domain/Entity.java b/domain/src/main/java/com/euonia/domain/Entity.java new file mode 100644 index 0000000..22c63b5 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/Entity.java @@ -0,0 +1,9 @@ +package com.euonia.domain; + +public interface Entity> { + ID getId(); + + void setId(ID id); + + Object[] getKeys(); +} diff --git a/domain/src/main/java/com/euonia/domain/EntityBase.java b/domain/src/main/java/com/euonia/domain/EntityBase.java new file mode 100644 index 0000000..96f395d --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/EntityBase.java @@ -0,0 +1,25 @@ +package com.euonia.domain; + +public abstract class EntityBase> implements Entity { + private ID id; + + @Override + public ID getId() { + return id; + } + + @Override + public void setId(ID id) { + this.id = id; + } + + @Override + public Object[] getKeys() { + return new Object[]{id}; + } + + @Override + public String toString() { + return String.format("%s{id=%s}", this.getClass().getSimpleName(), id); + } +} diff --git a/domain/src/main/java/com/euonia/domain/ValueObject.java b/domain/src/main/java/com/euonia/domain/ValueObject.java new file mode 100644 index 0000000..d539a92 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/ValueObject.java @@ -0,0 +1,128 @@ +package com.euonia.domain; + +import java.lang.reflect.Field; +import java.util.Objects; + +/** + * A base class for value objects that provides implementations for equals, hashCode, and compareTo methods. + * + * @param the type of the value object + */ +public class ValueObject> implements Comparable { + @SuppressWarnings("NullableProblems") + @Override + public int compareTo(T other) { + if (other == null) { + return 1; + } + + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var left = field.get(this); + var right = field.get(other); + + if (left == right) { + continue; + } + + if (left == null) { + return -1; + } + + if (right == null) { + return 1; + } + + if (left instanceof Comparable && right instanceof Comparable) { + @SuppressWarnings("unchecked") + int result = ((Comparable) left).compareTo(right); + if (result != 0) { + return result; + } + } else { + int result = left.toString().compareTo(right.toString()); + if (result != 0) { + return result; + } + } + + } catch (Exception e) { + return 0; + } + } + return 0; + } + + public boolean equals(T obj) { + if (obj == null) { + return false; + } + + if (this == obj) { + return true; + } + + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var left = field.get(this); + var right = field.get(obj); + + if (left == right) { + continue; + } + + if (left == null || right == null) { + return false; + } + + if (!Objects.equals(left, right)) { + return false; + } + + } catch (Exception e) { + return false; + } + } + return true; + } + + @SuppressWarnings("unchecked") + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + + if (obj == null || getClass() != obj.getClass()) { + return false; + } + + return equals((T) obj); + } + + @Override + public int hashCode() { + var hashCode = 31; + var changeMultiplier = false; + var fields = this.getClass().getDeclaredFields(); + for (Field field : fields) { + try { + field.setAccessible(true); + var value = field.get(this); + if (value != null) { + hashCode = hashCode * (changeMultiplier ? 59 : 114) + value.hashCode(); + changeMultiplier = !changeMultiplier; + } else { + hashCode ^= 13; + } + } catch (Exception e) { + continue; + } + } + return hashCode; + } +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java b/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java new file mode 100644 index 0000000..a070876 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/AuditRecord.java @@ -0,0 +1,70 @@ +package com.euonia.domain.auditing; + +import com.euonia.domain.EntityBase; + +import java.time.Instant; + +public class AuditRecord> extends EntityBase { + private final String entityName; + private final String entityId; + private final String action; + private final Instant timestamp; + private String comment; + private String userId; + private String userName; + + public AuditRecord(String entityName, String entityId, String action, Instant timestamp) { + this.entityName = entityName; + this.entityId = entityId; + this.action = action; + this.timestamp = timestamp; + } + + public AuditRecord(String entityName, String entityId, String action) { + this(entityName, entityId, action, Instant.now()); + } + + public AuditRecord(Class entityClass, String entityId, String action) { + this(entityClass.getSimpleName(), entityId, action, Instant.now()); + } + + public String getEntityName() { + return entityName; + } + + public String getEntityId() { + return entityId; + } + + public String getAction() { + return action; + } + + public Instant getTimestamp() { + return timestamp; + } + + public String getComment() { + return comment; + } + + public void setComment(String comment) { + this.comment = comment; + } + + public String getUserId() { + return userId; + } + + public void setUserId(String userId) { + this.userId = userId; + } + + public String getUserName() { + return userName; + } + + public void setUserName(String userName) { + this.userName = userName; + } +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java b/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java new file mode 100644 index 0000000..87a038d --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/AuditStore.java @@ -0,0 +1,10 @@ +package com.euonia.domain.auditing; + +/** + * Interface for storing audit records. Implementations can choose to store records in a database, file system, or any other storage mechanism. + * The save method is generic and can handle any type of audit record that extends the AuditRecord interface, allowing for flexibility in the types of records that can be stored. + * The ID type is also generic, allowing for different types of identifiers to be used for audit records, such as Long, String, UUID, etc. + */ +public interface AuditStore { + , ID extends Comparable> void save(T record); +} diff --git a/domain/src/main/java/com/euonia/domain/auditing/Audited.java b/domain/src/main/java/com/euonia/domain/auditing/Audited.java new file mode 100644 index 0000000..fb0072b --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/auditing/Audited.java @@ -0,0 +1,11 @@ +package com.euonia.domain.auditing; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.FIELD, ElementType.METHOD}) +public @interface Audited { +} diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java b/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java new file mode 100644 index 0000000..6591b91 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/ApplicationEvent.java @@ -0,0 +1,4 @@ +package com.euonia.domain.event; + +public interface ApplicationEvent extends Event { +} diff --git a/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java b/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java new file mode 100644 index 0000000..eb1fea8 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/ApplicationEventBase.java @@ -0,0 +1,4 @@ +package com.euonia.domain.event; + +public abstract class ApplicationEventBase extends EventBase implements ApplicationEvent { +} diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEvent.java b/domain/src/main/java/com/euonia/domain/event/DomainEvent.java new file mode 100644 index 0000000..ac9b034 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/DomainEvent.java @@ -0,0 +1,26 @@ +package com.euonia.domain.event; + +import com.euonia.domain.Aggregate; + +/** + * The DomainEvent interface represents a specific type of event that is associated with a particular aggregate in the domain model. It extends the Event interface, indicating that it inherits the properties and methods defined in the Event interface while adding additional functionality specific to domain events. + * Domain events are used to capture and represent significant occurrences or changes that happen within the context of a specific aggregate. They allow for communication and coordination between different components of the system, enabling the propagation of important information about changes in the state of the aggregate to other parts of the system that may be interested in those changes. + * By attaching a domain event to an aggregate, you can ensure that the event is associated with the specific aggregate instance and can be processed accordingly. The getEventAggregate method allows you to retrieve the associated EventAggregate, which contains relevant information about the event and its context within the domain model. + */ +public interface DomainEvent extends Event { + + /** + * Attaches the domain event to a specific aggregate. This method allows you to associate the event with a particular aggregate instance, enabling the event to be processed in the context of that aggregate. The generic type parameter represents the type of the identifier used by the aggregate, which must be comparable. + * + * @param aggregate the aggregate to which the domain event should be attached + * @param the type of the identifier used by the aggregate + */ + > void attach(Aggregate aggregate); + + /** + * Retrieves the EventAggregate associated with this domain event. The EventAggregate contains relevant information about the event and its context within the domain model, such as the event's unique identifier, timestamp, type name, event intent, originator type, originator ID, event payload, and event sequence. This method allows you to access the details of the event and its associated aggregate for further processing or analysis. + * + * @return the EventAggregate associated with this domain event + */ + EventAggregate getEventAggregate(); +} diff --git a/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java b/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java new file mode 100644 index 0000000..227dd9f --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/DomainEventBase.java @@ -0,0 +1,57 @@ +package com.euonia.domain.event; + +import com.euonia.core.ObjectId; +import com.euonia.domain.Aggregate; + +import java.time.Instant; + +/** + * Base implementation of a domain event, which can be extended by specific domain events. + * It provides common properties and methods for handling domain events, such as attaching to an aggregate and converting to an event aggregate. + */ +public abstract class DomainEventBase extends EventBase implements DomainEvent { + + private Object aggregatePayload; + + @Override + public > void attach(Aggregate aggregate) { + setOriginatorId(aggregate.getId().toString()); + setOriginatorType(aggregate.getClass().getTypeName()); + setAggregatePayload(aggregate); + } + + @Override + public EventAggregate getEventAggregate() { + return new EventAggregate() {{ + setId(ObjectId.guid().toString()); + setTypeName(getClass().getTypeName()); + setEventIntent(getEventIntent()); + setEventId(getEventId()); + setTimestamp(Instant.now()); + setOriginatorId(getOriginatorId()); + setOriginatorType(getOriginatorType()); + setEventSequence(getSequence()); + setAggregatePayload(this); + }}; + } + + public Object getAggregatePayload() { + return aggregatePayload; + } + + public void setAggregatePayload(Object aggregatePayload) { + this.aggregatePayload = aggregatePayload; + } + + @SuppressWarnings("unchecked") + public > A getAggregate(Class type) { + if (aggregatePayload == null) { + return null; + } + + if (aggregatePayload.getClass().isAssignableFrom(type)) { + return (A) aggregatePayload; + } + return null; + } +} diff --git a/domain/src/main/java/com/euonia/domain/event/Event.java b/domain/src/main/java/com/euonia/domain/event/Event.java new file mode 100644 index 0000000..68513db --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/Event.java @@ -0,0 +1,81 @@ +package com.euonia.domain.event; + +/** + * The Event interface represents a generic event in the domain model. It defines the basic properties and methods that all events should have. + * Events are used to capture and represent significant occurrences or changes in the system, allowing for communication and coordination between different components. + */ +public interface Event { + /** + * Gets the unique identifier of the event, which can be used to track and reference the event throughout its lifecycle. + * This identifier is typically generated when the event is created and remains constant for the duration of the event's existence. + * + * @return the unique identifier of the event + */ + String getEventId(); + + /** + * Sets the unique identifier of the event. + * This method allows you to assign a specific identifier to the event, which can be useful for tracking and referencing purposes. + * + * @param eventId the unique identifier of the event + */ + void setEventId(String eventId); + + /** + * Gets the sequence number of the event, which can be used to determine the order of events. + * + * @return the sequence number of the event + */ + long getSequence(); + + /** + * Sets the sequence number of the event, which can be used to determine the order of events. + * + * @param sequence the sequence number of the event + */ + void setSequence(long sequence); + + /** + * Gets the event intent, which represents the purpose or meaning of the event. + * It can be used to categorize or identify the type of event being processed. + * + * @return the intent of the event + */ + String getEventIntent(); + + /** + * Sets the event intent, which represents the purpose or meaning of the event. + * It can be used to categorize or identify the type of event being processed. + * + * @param eventIntent the intent of the event + */ + void setEventIntent(String eventIntent); + + /** + * Gets the originator type of the event, which indicates the source or origin of the event. + * + * @return the originator type of the event + */ + String getOriginatorType(); + + /** + * Sets the originator type of the event, which indicates the source or origin of the event. + * + * @param originatorType the originator type of the event + */ + void setOriginatorType(String originatorType); + + /** + * Gets the originator ID of the event, which uniquely identifies the source or origin of the event. + * + * @return the originator ID of the event + */ + String getOriginatorId(); + + /** + * Sets the originator ID of the event, which uniquely identifies the source or origin of the event. + * + * @param originatorId the originator ID of the event + */ + void setOriginatorId(String originatorId); +} diff --git a/domain/src/main/java/com/euonia/domain/event/EventAggregate.java b/domain/src/main/java/com/euonia/domain/event/EventAggregate.java new file mode 100644 index 0000000..eb1ec77 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/EventAggregate.java @@ -0,0 +1,120 @@ +package com.euonia.domain.event; + +import com.euonia.domain.Aggregate; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZonedDateTime; + +public class EventAggregate implements Aggregate { + + private String id; + private String eventId; + private Instant timestamp; + private String typeName; + private String eventIntent; + private String originatorType; + private String originatorId; + private Object eventPayload; + private long eventSequence; + + @Override + public String getId() { + return id; + } + + @Override + public void setId(String id) { + this.id = id; + } + + public String getEventId() { + return eventId; + } + + public void setEventId(String eventId) { + this.eventId = eventId; + } + + public Instant getTimestamp() { + return timestamp; + } + + public void setTimestamp(Instant timestamp) { + this.timestamp = timestamp; + } + + public void setTimestamp(String timestamp) { + this.timestamp = Instant.parse(timestamp); + } + + public void setTimestamp(long timestamp) { + this.timestamp = Instant.ofEpochMilli(timestamp); + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp.toInstant(java.time.ZoneOffset.UTC); + } + + public void setTimestamp(ZonedDateTime timestamp) { + this.timestamp = timestamp.toInstant(); + } + + public String getTypeName() { + return typeName; + } + + public void setTypeName(String typeName) { + this.typeName = typeName; + } + + public String getEventIntent() { + return eventIntent; + } + + public void setEventIntent(String eventIntent) { + this.eventIntent = eventIntent; + } + + public String getOriginatorType() { + return originatorType; + } + + public void setOriginatorType(String originatorType) { + this.originatorType = originatorType; + } + + public String getOriginatorId() { + return originatorId; + } + + public void setOriginatorId(String originatorId) { + this.originatorId = originatorId; + } + + public Object getEventPayload() { + return eventPayload; + } + + public void setEventPayload(Object eventPayload) { + this.eventPayload = eventPayload; + } + + public long getEventSequence() { + return eventSequence; + } + + public void setEventSequence(long eventSequence) { + this.eventSequence = eventSequence; + } + + @Override + public Object[] getKeys() { + return new Object[]{id}; + } + + @Override + public String toString() { + return eventIntent; + } +} diff --git a/domain/src/main/java/com/euonia/domain/event/EventBase.java b/domain/src/main/java/com/euonia/domain/event/EventBase.java new file mode 100644 index 0000000..d2a5249 --- /dev/null +++ b/domain/src/main/java/com/euonia/domain/event/EventBase.java @@ -0,0 +1,85 @@ +package com.euonia.domain.event; + +import com.euonia.core.ObjectId; +import com.euonia.reflection.TypeHelper; + +import java.time.Instant; +import java.util.HashMap; +import java.util.Map; + +/** + * Base implementation of the Event interface. + * This class provides common properties and methods for all events, such as event ID, event intent, originator type, and originator ID. + * It also includes a sequence number to maintain the order of events. + * Subclasses can extend this base class to create specific types of events with additional properties and behavior. + */ +public abstract class EventBase implements Event { + private static final String PROPERTY_ID = "nerosoft.euonia.internal.event.id"; + private static final String PROPERTY_EVENT_INTENT = "nerosoft.euonia.internal.event.intent"; + private static final String PROPERTY_ORIGINATOR_TYPE = "nerosoft.euonia.internal.event.originator.type"; + private static final String PROPERTY_ORIGINATOR_ID = "nerosoft.euonia.internal.event.originator.id"; + private final Map properties = new HashMap<>(); + private long sequence = Instant.now().toEpochMilli(); + + protected EventBase() { + var type = getClass(); + properties.put(PROPERTY_ID, ObjectId.guid().toString()); + setEventIntent(type.getName()); + } + + public final String get(String property) { + return properties.getOrDefault(property, null); + } + + public final void set(String property, String value) { + properties.put(property, value); + } + + public T get(String property, Class castType) { + var value = properties.getOrDefault(property, null); + if (value == null) { + return null; + } + return TypeHelper.coerceValue(castType, value); + } + + public String getEventIntent() { + return get(PROPERTY_EVENT_INTENT); + } + + public void setEventIntent(String eventIntent) { + set(PROPERTY_EVENT_INTENT, eventIntent); + } + + public final String getEventId() { + return get(PROPERTY_ID); + } + + public final void setEventId(String eventId) { + set(PROPERTY_ID, eventId); + } + + public final long getSequence() { + return sequence; + } + + public final void setSequence(long sequence) { + this.sequence = sequence; + } + + public String getOriginatorType() { + return get(PROPERTY_ORIGINATOR_TYPE); + } + + public void setOriginatorType(String originatorType) { + set(PROPERTY_ORIGINATOR_TYPE, originatorType); + } + + public String getOriginatorId() { + return get(PROPERTY_ORIGINATOR_ID); + } + + public void setOriginatorId(String originatorId) { + set(PROPERTY_ORIGINATOR_ID, originatorId); + } +} diff --git a/osba/pom.xml b/osba/pom.xml new file mode 100644 index 0000000..71401e2 --- /dev/null +++ b/osba/pom.xml @@ -0,0 +1,26 @@ + + + 4.0.0 + + com.euonia + parent + ${revision} + + + osba + euonia osba module + + Object-oriented scalable business architecture + + + + com.euonia + core + ${revision} + compile + + + + diff --git a/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java new file mode 100644 index 0000000..c632c36 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/BusinessObjectFactory.java @@ -0,0 +1,236 @@ +package com.euonia.factory; + +import com.euonia.core.PriorityValueFinder; +import com.euonia.factory.annotation.*; +import com.euonia.osba.*; +import com.euonia.osba.abstracts.UseBusinessContext; +import com.euonia.reflection.ObjectReflector; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Objects; +import java.util.function.Function; + +/** + * BusinessObjectFactory is an implementation of the ObjectFactory interface that uses reflection to create, fetch, insert, update, save, execute, and delete business objects based on annotated factory methods. + * It supports integration with a bean factory for object instantiation and handles different object states for saving operations. + */ +@SuppressWarnings("UnusedReturnValue") +public class BusinessObjectFactory implements ObjectFactory { + + private Function, ?> beanFactory; + + /** + * Configures the BusinessObjectFactory to use the provided bean factory for object instantiation. + * + * @param beanFactory the bean factory function to use for creating objects + * @return the current instance of BusinessObjectFactory + */ + public BusinessObjectFactory use(Function, ?> beanFactory) { + this.beanFactory = beanFactory; + return this; + } + + /** + * Creates an instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryCreate, using the provided criteria as arguments. + * + * @param type The class of the object to be created. + * @param criteria The arguments to be used for creating the object. + * @param The type of the object to be created. + * @return The created object. + */ + @Override + public T create(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryCreate.class, criteria); + var target = getObjectInstance(type); + if (target instanceof ObservableObject editableObject) { + editableObject.markAsNew(); + } + + invoke(method, target, criteria); + return target; + } + + /** + * Fetches an instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryFetch, using the provided criteria as arguments. + * + * @param type The class of the object to be retrieved. + * @param criteria The arguments to be used for retrieving the object. + * @param The type of the object to be retrieved. + * @return The retrieved object. + */ + @Override + public T fetch(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryFetch.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + /** + * Inserts a new instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryInsert, using the provided criteria as arguments. + * + * @param type The class of the object to be inserted. + * @param criteria The arguments to be used for inserting the object. + * @param The type of the object to be inserted. + * @return The inserted object. + */ + @Override + public T insert(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryInsert.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + /** + * Updates an existing instance of the specified type by finding and invoking the appropriate factory method annotated with @FactoryUpdate, using the provided criteria as arguments. + * + * @param type The class of the object to be updated. + * @param criteria The arguments to be used for updating the object. + * @param The type of the object to be updated. + * @return The updated object. + */ + @Override + public T update(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + /** + * Saves the provided object by determining its state and invoking the appropriate factory method based on annotations. + * + * @param type The class of the object to be saved. + * @param target The instance of the object to be saved. + * @param The type of the object to be saved. + * @return The saved object. + */ + @Override + public T save(Class type, T target) { + Method method; + if (target instanceof ObservableObject editableObject) { + if (editableObject.getState() == ObjectEditState.NEW) { + method = ObjectReflector.findFactoryMethod(type, FactoryInsert.class, new Object[0]); + } else if (editableObject.getState() == ObjectEditState.CHANGED) { + method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + } else if (editableObject.getState() == ObjectEditState.DELETED) { + method = ObjectReflector.findFactoryMethod(type, FactoryDelete.class, new Object[0]); + } else { + throw new IllegalArgumentException("Unexpected value: " + editableObject.getState()); + } + } else if (target instanceof ExecutableObject) { + method = ObjectReflector.findFactoryMethod(type, FactoryExecute.class, new Object[0]); + } else if (target instanceof ReadOnlyObject) { + throw new UnsupportedOperationException("Cannot save a read-only object of type: " + type.getName()); + } else { + method = ObjectReflector.findFactoryMethod(type, FactoryUpdate.class, new Object[0]); + } + invoke(method, target); + return target; + } + + /** + * Executes the provided object by finding and invoking the appropriate factory method annotated with @FactoryExecute, using the provided criteria as arguments. + * + * @param type The class of the object to be executed. + * @param criteria The arguments to be used for executing the object. + * @param The type of the object to be executed. + * @return The executed object. + */ + @Override + public T execute(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryExecute.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + return target; + } + + /** + * Deletes the specified object by finding and invoking the appropriate factory method annotated with @FactoryDelete, using the provided criteria as arguments. + * + * @param type The class of the object to be deleted. + * @param criteria The arguments to be used for deleting the object. + * @param The type of the object to be deleted. + */ + @Override + public void delete(Class type, Object... criteria) { + var method = ObjectReflector.findFactoryMethod(type, FactoryDelete.class, criteria); + var target = getObjectInstance(type); + invoke(method, target, criteria); + } + + /** + * Creates an instance of the specified type using the bean factory if available, or falls back to reflection-based instantiation. + * If the created object implements UseBusinessContext, it sets the business context for that object. + * + * @param type The class of the object to be created. + * @param The type of the object to be created. + * @return The created object instance. + */ + @SuppressWarnings("unchecked") + private T getObjectInstance(Class type) { + T object = PriorityValueFinder.find(queue -> { + if (beanFactory != null) { + queue.add(() -> { + try { + return (T) beanFactory.apply(type); + } catch (Exception e) { + return null; + } + }, 1); + } + queue.add(() -> { + try { + var constructors = Arrays.stream(type.getDeclaredConstructors()) + .sorted((a, b) -> Integer.compare(b.getParameterCount(), a.getParameterCount())) + .toList(); + + var ctor = constructors.stream().findFirst().orElseThrow(); + + var parameters = ctor.getParameters(); + if (parameters.length == 0) { + return (T) ctor.newInstance(); + } else { + var args = new Object[parameters.length]; + for (int i = 0; i < parameters.length; i++) { + var parameterType = parameters[i].getType(); + args[i] = getObjectInstance(parameterType); + } + return (T) ctor.newInstance(args); + } + } catch (InstantiationException | IllegalAccessException | IllegalArgumentException | + InvocationTargetException e) { + throw new RuntimeException("Failed to create instance of " + type.getName(), e); + } + }, 2); + }, Objects::nonNull, null); + + if (object instanceof UseBusinessContext businessObject) { + businessObject.setBusinessContext(new BusinessContext(this::getObjectInstance, this)); + } + + return object; + } + + /** + * Invokes the specified method on the target object with the given criteria as arguments, handling any exceptions that may occur during invocation. + * + * @param method The method to be invoked. + * @param target The target object on which the method is to be invoked. + * @param criteria The arguments to be used for invoking the method. + * @param The type of the target object. + */ + private void invoke(Method method, T target, Object... criteria) { + try { + method.setAccessible(true); + method.invoke(target, criteria); + } catch (IllegalAccessException | InvocationTargetException e) { + throw new RuntimeException( + "Failed to invoke method: " + method.getName() + " on target: " + target.getClass().getName(), e); + } + } + +} diff --git a/osba/src/main/java/com/euonia/factory/ObjectFactory.java b/osba/src/main/java/com/euonia/factory/ObjectFactory.java new file mode 100644 index 0000000..cab0150 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/ObjectFactory.java @@ -0,0 +1,159 @@ +package com.euonia.factory; + +/** + * ObjectFactory is an interface that defines methods for business object + * creation, retrieval, update, and deletion. + * It provides a standardized way to manage the lifecycle of business objects in + * an application. The methods include: + *

+ * - create: Creates a new instance of the specified type with the given + * arguments. + *

+ *

+ * - fetch: Retrieves an existing instance of the specified type based on the + * given arguments. + *

+ *

+ * - insert: Inserts a new instance of the specified type into the data store + * with the given arguments. + *

+ *

+ * - update: Updates an existing instance of the specified type in the data + * store with the given arguments. + *

+ *

+ * - save: Saves the provided instance of the specified type to the data store, + * either by inserting or updating it as necessary. + *

+ *

+ * - execute: Executes a specific operation related to the specified type with + * the given arguments, which may involve complex business logic. + *

+ *

+ * - delete: Deletes an existing instance of the specified type from the data + * store based on the given arguments. + *

+ */ +public interface ObjectFactory { + /** + * Creates a new instance of the specified type with the given arguments. + * The implementation of this method should handle the instantiation logic based + * on the type and arguments provided. + * This may involve using reflection, dependency injection, or other mechanisms + * to create the object. + * The method should return the newly created instance of the specified type. + * + * @param The type of the object to be created. + * @param type The class of the object to be created. + * @param args The arguments to be used for creating the object. + * @return The newly created instance of the specified type. + */ + T create(Class type, Object... args); + + /** + * Retrieves an existing instance of the specified type based on the given + * arguments. + * The implementation of this method should handle the retrieval logic based on + * the type and arguments provided. + * This may involve querying a database, accessing a cache, or other mechanisms + * to find the object. + * The method should return the retrieved instance of the specified type, or + * null if no matching instance is found. + * + * @param The type of the object to be retrieved. + * @param type The class of the object to be retrieved. + * @param args The arguments to be used for retrieving the object. + * @return The retrieved instance of the specified type, or null if no matching + * instance is found. + */ + T fetch(Class type, Object... args); + + /** + * Inserts a new instance of the specified type into the data store with the + * given arguments. + * The implementation of this method should handle the insertion logic based on + * the type and arguments provided. + * This may involve validating the input, preparing the data for insertion, and + * executing the necessary database operations to persist the object. + * The method should return the newly inserted instance of the specified type, + * which may include any generated identifiers or additional data from the + * data store. + * + * @param The type of the object to be inserted. + * @param type The class of the object to be inserted. + * @param args The arguments to be used for inserting the object. + * @return The newly inserted instance of the specified type, which may include + * any generated identifiers or additional data from the data store. + */ + T insert(Class type, Object... args); + + /** + * Updates an existing instance of the specified type in the data store with the + * given arguments. + * The implementation of this method should handle the update logic based on the + * type and arguments provided. + * This may involve validating the input, preparing the data for update, and + * executing the necessary database operations to modify the existing object. + * The method should return the updated instance of the specified type, which + * may include any changes made during the update process. + * + * @param The type of the object to be updated. + * @param type The class of the object to be updated. + * @param args The arguments to be used for updating the object. + * @return The updated instance of the specified type, which may include any + * changes made during the update process. + */ + T update(Class type, Object... args); + + /** + * Saves the provided instance of the specified type to the data store, either + * by inserting or updating it as necessary. + * The implementation of this method should determine whether to insert a new + * instance or update an existing one based on the state of the provided object + * and any relevant identifiers. + * This may involve validating the input, preparing the data for persistence, and + * executing the necessary database operations to save the object. + * The method should return the saved instance of the specified type, which may + * include any changes made during the save process, such as generated + * identifiers or updated fields. + * + * @param The type of the object to be saved. + * @param type The class of the object to be saved. + * @param target The instance of the object to be saved. + * @return The saved instance of the specified type, which may include any + * changes made during the save process, such as generated identifiers + * or updated fields. + */ + T save(Class type, T target); + + /** + * Executes a custom operation on the specified type with the given arguments. + * The implementation of this method should handle the execution logic based on + * the type and arguments provided. + * This may involve validating the input, preparing the data for execution, and + * executing the necessary operations. + * The method should return the result of the execution, which may include any + * changes made during the process. + * + * @param The type of the object to be executed. + * @param type The class of the object to be executed. + * @param args The arguments to be used for executing the object. + * @return The result of the execution, which may include any changes made during + * the process. + */ + T execute(Class type, Object... args); + + /** + * Deletes an existing instance of the specified type from the data store with the + * given arguments. + * The implementation of this method should handle the deletion logic based on the + * type and arguments provided. + * This may involve validating the input, preparing the data for deletion, and + * executing the necessary database operations to remove the object. + * + * @param The type of the object to be deleted. + * @param type The class of the object to be deleted. + * @param args The arguments to be used for deleting the object. + */ + void delete(Class type, Object... args); +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java new file mode 100644 index 0000000..f351ee1 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryCreate.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryCreate { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java new file mode 100644 index 0000000..c8e91fb --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryDelete.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryDelete { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java new file mode 100644 index 0000000..ff9a565 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryExecute.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryExecute { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java new file mode 100644 index 0000000..8a1cfc6 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryFetch.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryFetch { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java new file mode 100644 index 0000000..2d5459e --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryInsert.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryInsert { +} diff --git a/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java b/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java new file mode 100644 index 0000000..3ec40f6 --- /dev/null +++ b/osba/src/main/java/com/euonia/factory/annotation/FactoryUpdate.java @@ -0,0 +1,11 @@ +package com.euonia.factory.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface FactoryUpdate { +} diff --git a/osba/src/main/java/com/euonia/osba/BusinessContext.java b/osba/src/main/java/com/euonia/osba/BusinessContext.java new file mode 100644 index 0000000..3e59d92 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/BusinessContext.java @@ -0,0 +1,51 @@ +package com.euonia.osba; + +import com.euonia.factory.ObjectFactory; + +import java.util.Objects; +import java.util.function.Function; + +/** + * BusinessContext provides a small service-resolution and instance-creation + * gateway for business objects created by OSBA factories. + */ +public final class BusinessContext { + private final Function, ?> instanceCreator; + private final ObjectFactory objectFactory; + + /** + * Creates a new instance of BusinessContext with the specified instance creator function and ObjectFactory. + * + * @param instanceCreator A function that creates instances of the specified type. + * @param objectFactory The ObjectFactory to be used by this BusinessContext. + */ + public BusinessContext(Function, ?> instanceCreator, ObjectFactory objectFactory) { + this.instanceCreator = instanceCreator; + this.objectFactory = objectFactory; + } + + /** + * Gets an instance of the specified type from the context, creating it if necessary. + * + * @param objectType The type of the object to retrieve or create. + * @param The type of the object. + * @return An instance of the specified type. + */ + @SuppressWarnings("unchecked") + public T getOrCreateObject(Class objectType) { + Objects.requireNonNull(objectType, "objectType"); + if (instanceCreator == null) { + throw new IllegalStateException("BusinessContext does not support instance creation."); + } + return (T) instanceCreator.apply(objectType); + } + + /** + * Gets the ObjectFactory associated with this BusinessContext, which can be used to create, fetch, insert, update, save, execute, and delete business objects based on annotated factory methods. + * + * @return The ObjectFactory associated with this BusinessContext. + */ + public ObjectFactory getObjectFactory() { + return objectFactory; + } +} diff --git a/osba/src/main/java/com/euonia/osba/BusinessObject.java b/osba/src/main/java/com/euonia/osba/BusinessObject.java new file mode 100644 index 0000000..5257c01 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/BusinessObject.java @@ -0,0 +1,428 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.RuleCheckable; +import com.euonia.osba.abstracts.UseBusinessContext; +import com.euonia.osba.rules.BrokenRuleCollection; +import com.euonia.osba.rules.RuleManager; +import com.euonia.osba.rules.Rules; +import com.euonia.reflection.FieldDataManager; +import com.euonia.reflection.PropertyInfo; +import com.euonia.reflection.PropertyInfoManager; +import com.euonia.security.UnauthorizedAccessException; + +import java.beans.PropertyChangeListener; +import java.beans.PropertyChangeSupport; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Supplier; + +public abstract class BusinessObject> implements UseBusinessContext, RuleCheckable { + private final List> changedProperties = new ArrayList<>(); + private volatile FieldDataManager fieldManager; + protected final PropertyChangeSupport support = new PropertyChangeSupport(this); + + private BusinessContext businessContext; + + @Override + public void setBusinessContext(BusinessContext businessContext) { + this.businessContext = businessContext; + onBusinessContextSet(); + initialize(); + initializeRules(); + } + + @Override + public BusinessContext getBusinessContext() { + return businessContext; + } + + protected void onBusinessContextSet() { + + } + + protected void initialize() { + + } + + private void initializeRules() { + var rules = RuleManager.getRules(this.getClass()); + if (rules.isInitialized()) { + return; + } + synchronized (rules) { + if (rules.isInitialized()) { + return; + } + + try { + addRules(); + rules.setInitialized(true); + } catch (Exception e) { + RuleManager.cleanRules(this.getClass()); + throw e; + } + } + } + + public FieldDataManager getFieldManager() { + if (fieldManager == null) { + synchronized (this) { + if (fieldManager == null) { + fieldManager = new FieldDataManager(this.getClass()); + } + } + } + return fieldManager; + } + + private volatile Rules rules; + + protected Rules getRules() { + if (rules == null) { + synchronized (this) { + if (rules == null) { + rules = new Rules(this); + } + } + } + return rules; + } + + protected void addRules() { + + } + + protected void checkPropertyRules(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + var properties = getRules().checkObjectRulesAsync() + .thenApply(brokenProperties -> + brokenProperties.stream() + .filter(p -> p.equals(propertyInfo.getName())) + .toList()) + .join(); + onPropertyChanged(propertyInfo, oldValue, newValue); + } + + // region RuleCheckable + @Override + public boolean isValid() { + return getRules().isValid(); + } + + @Override + public void ruleCheckComplete(PropertyInfo property) { + onPropertyChanged(property, null, null); + } + + @Override + public void ruleCheckComplete(String property) { + onPropertyChanged(property, null, null); + } + + @Override + public void allRulesComplete() { + + } + + @Override + public void suspendRuleChecking() { + getRules().setSuppressRuleChecking(true); + } + + @Override + public void resumeRuleChecking() { + getRules().setSuppressRuleChecking(false); + } + + @Override + public BrokenRuleCollection getBrokenRules() { + return rules.getBrokenRules(); + } + + // endregion + + // region Property change + protected List getPropertiesToCheckRulesOnChanged() { + return List.of(); + } + + public List> getChangedProperties() { + return List.copyOf(changedProperties); + } + + public boolean hasChangedProperties() { + return !changedProperties.isEmpty(); + } + + public final void addPropertyChangeListener(PropertyChangeListener listener) { + support.addPropertyChangeListener(listener); + } + + protected void onPropertyChanged(String propertyName, Object oldValue, Object newValue) { + support.firePropertyChange(propertyName, oldValue, newValue); + } + + protected void onPropertyChanged(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + onPropertyChanged(propertyInfo.getName(), oldValue, newValue); + } + + protected final void propertyHasChanged(PropertyInfo propertyInfo, Object oldValue, Object newValue) { + if (!changedProperties.contains(propertyInfo)) { + changedProperties.add(propertyInfo); + } + if (getPropertiesToCheckRulesOnChanged().contains(propertyInfo.getName())) { + checkPropertyRules(propertyInfo, oldValue, newValue); + } else { + onPropertyChanged(propertyInfo, oldValue, newValue); + } + } + // endregion + + // region Load Properties + public boolean fieldExists(PropertyInfo propertyInfo) { + return getFieldManager().fieldExists(propertyInfo); + } + + public boolean fieldExists(String fieldName) { + var propertyInfo = getFieldManager().getRegisteredProperty(fieldName); + return fieldExists(propertyInfo); + } + + public T readProperty(PropertyInfo propertyInfo) { + T result; + var field = getFieldManager().getFieldData(propertyInfo); + if (field != null) { + result = field.getValue(); + } else { + result = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, result); + } + return result; + } + + public Object readProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return readProperty(propertyInfo); + } + + public final void loadProperty(PropertyInfo propertyInfo, T value) { + T oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, oldValue); + } else { + oldValue = fieldData.getValue(); + } + loadPropertyValue(propertyInfo, oldValue, value, false); + } + + protected final void loadPropertyValue(PropertyInfo propertyInfo, T oldValue, T newValue, boolean markAsChanged) { + var isValueChanged = !Objects.equals(oldValue, newValue); + if (!isValueChanged) { + return; + } + if (markAsChanged) { + getFieldManager().setFieldData(propertyInfo, newValue); + propertyHasChanged(propertyInfo, oldValue, newValue); + } else { + getFieldManager().loadFieldData(propertyInfo, newValue); + } + } + + protected final boolean valuesDiffer(PropertyInfo propertyInfo, T oldValue, T newValue) { + boolean isDifferent; + if (oldValue == null) { + isDifferent = newValue != null; + } else { + if (BusinessObject.class.isAssignableFrom(propertyInfo.getType())) { + //if (propertyInfo.getType().isAssignableFrom(BusinessObject.class)) { + isDifferent = !Objects.equals(oldValue, newValue); + } else { + isDifferent = !oldValue.equals(newValue); + } + } + return isDifferent; + } + // endregion + + // region Authorization + public boolean canReadProperty(PropertyInfo propertyInfo) { + return true; + } + + public boolean canReadProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canReadProperty(propertyInfo); + } + + public final boolean canReadProperty(PropertyInfo propertyInfo, boolean throwOnFalse) { + var result = canReadProperty(propertyInfo); + if (!result && throwOnFalse) { + throw new UnauthorizedAccessException(String.format("Property '%s' is not readable.", propertyInfo.getName())); + } + return result; + } + + public final boolean canReadProperty(String propertyName, boolean throwOnFalse) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canReadProperty(propertyInfo, throwOnFalse); + } + + public boolean canWriteProperty(PropertyInfo propertyInfo) { + return true; + } + + public final boolean canWriteProperty(PropertyInfo propertyInfo, boolean throwOnFalse) { + var result = canWriteProperty(propertyInfo); + if (!result && throwOnFalse) { + throw new UnauthorizedAccessException(String.format("Property '%s' is not writable.", propertyInfo.getName())); + } + return result; + } + + public boolean canWriteProperty(String propertyName) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canWriteProperty(propertyInfo); + } + + public final boolean canWriteProperty(String propertyName, boolean throwOnFalse) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + return canWriteProperty(propertyInfo, throwOnFalse); + } + // endregion + + // region Property Checks + private boolean isBypassingRuleCheck = false; + + protected boolean isBypassingRuleCheck() { + return isBypassingRuleCheck; + } + + private BypassRuleCheckObject internalBypassRuleChecks; + + protected final BypassRuleCheckObject bypassRuleChecks() { + return BypassRuleCheckObject.create(this); + } + + protected static final class BypassRuleCheckObject> implements AutoCloseable { + + private BusinessObject businessObject; + + protected BypassRuleCheckObject(BusinessObject businessObject) { + this.businessObject = businessObject; + this.businessObject.isBypassingRuleCheck = true; + } + + public synchronized static > BypassRuleCheckObject create(BusinessObject businessObject) { + businessObject.internalBypassRuleChecks = new BypassRuleCheckObject<>(businessObject); + businessObject.internalBypassRuleChecks.incrementRefCount(); + return businessObject.internalBypassRuleChecks; + } + + @Override + public void close() { + decrementRefCount(); + } + + private int refCount = 0; + + private synchronized void incrementRefCount() { + refCount++; + } + + private synchronized void decrementRefCount() { + refCount--; + if (refCount != 0) { + return; + } + + this.businessObject.isBypassingRuleCheck = false; + this.businessObject.internalBypassRuleChecks = null; + this.businessObject = null; + } + + } + // endregion + + // region Register property + + /** + * Registers a property with the specified property info. + * + * @param propertyInfo the property info to register + * @param the type of the property + * @return the registered property info + */ + @SuppressWarnings("unchecked") + protected PropertyInfo registerProperty(PropertyInfo propertyInfo) { + return (PropertyInfo) PropertyInfoManager.registerProperty(getClass(), propertyInfo); + } + + /** + * Registers a property with the specified type and name. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName) { + return registerProperty(new PropertyInfo<>(type, propertyName, null, getClass(), null)); + } + + /** + * Registers a property with the specified type, name, and default value. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param defaultValue the default value supplier for the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, Supplier defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, null, getClass(), defaultValue)); + } + + /** + * Registers a property with the specified type, name, and friendly name. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), null)); + } + + /** + * Registers a property with the specified type, name, friendly name, and default value. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param defaultValue the default value of the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, V defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), () -> defaultValue)); + } + + /** + * Registers a property with the specified type, name, friendly name, and default value supplier. + * + * @param type the type of the property + * @param propertyName the name of the property + * @param friendlyName the friendly name of the property + * @param defaultValue the default value supplier for the property + * @param the type of the property + * @return the registered property info + */ + protected PropertyInfo registerProperty(Class type, String propertyName, String friendlyName, Supplier defaultValue) { + return registerProperty(new PropertyInfo<>(type, propertyName, friendlyName, getClass(), defaultValue)); + } + // endregion +} diff --git a/osba/src/main/java/com/euonia/osba/EditableObject.java b/osba/src/main/java/com/euonia/osba/EditableObject.java new file mode 100644 index 0000000..f3a3d63 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/EditableObject.java @@ -0,0 +1,125 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.Savable; +import com.euonia.osba.rules.BrokenRule; +import com.euonia.osba.rules.RuleCheckException; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Flow.*; +import java.util.concurrent.SubmissionPublisher; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +public abstract class EditableObject> extends ObservableObject implements Savable { + + private final Publisher savedEventPublisher = new SubmissionPublisher<>(); + + public final void onSaved(Consumer listener) { + if (listener != null) { + savedEventPublisher.subscribe(new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(SavedEventArgs item) { + listener.accept(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }); + } + } + + protected void onSaved(T newObject, Throwable error, Object userState) { + ((SubmissionPublisher) savedEventPublisher).submit(new SavedEventArgs(newObject, error, userState)); + } + + @Override + public void saveComplete(T newObject) { + onSaved(newObject, null, null); + } + + @Override + public T save(boolean forceUpdate) { + return save(forceUpdate, null); + } + + /** + * Saves the current object to the database or other persistent storage. + * If forceUpdate is true, the object will be saved even if it has not been marked as changed. + * The userState parameter can be used to pass additional information to the onSaved event. + * + * @param forceUpdate whether to force the update even if the object has not been marked as changed + * @param userState additional information to pass to the onSaved event + * @return the saved object + */ + @SuppressWarnings({"SameParameterValue", "unchecked"}) + protected T save(boolean forceUpdate, Object userState) { + if (getState() == ObjectEditState.NONE) { + if (forceUpdate) { + markAsChanged(); + } else { + return (T) this; + } + } + + CompletableFuture> validations; + + if (!isDeleted() || isCheckObjectRulesOnDelete()) { +// validations = getRules().checkObjectRulesAsync() +// .exceptionally(error -> { +// onSaved(null, error, userState); +// return null; +// }); + validations = getRules().checkObjectRulesAsync(); + } else { + validations = CompletableFuture.completedFuture(List.of()); + } + + return validations.thenCompose(ignored -> { + if (!isValid() && (!isDeleted() || isCheckObjectRulesOnDelete())) { + var errors = getRules().getBrokenRules() + .stream() + .collect(Collectors.groupingBy(BrokenRule::getProperty, Collectors.mapping(BrokenRule::getDescription, Collectors.toList()))); + return CompletableFuture.failedFuture(new RuleCheckException(errors)); + } + + return CompletableFuture.supplyAsync(() -> { + try { + markAsBusy(); + T result = getBusinessContext().getObjectFactory().save((Class) getClass(), (T) this); + onSaved(result, null, userState); + return result; + } catch (Throwable error) { + onSaved(null, error, userState); + throw new RuntimeException(error); + } finally { + markAsIdle(); + } + }); + }).join(); + } + + protected void create() { + } + + protected void insert() { + } + + protected void update() { + } + + protected void delete() { + } +} diff --git a/osba/src/main/java/com/euonia/osba/ExecutableObject.java b/osba/src/main/java/com/euonia/osba/ExecutableObject.java new file mode 100644 index 0000000..ed4a066 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ExecutableObject.java @@ -0,0 +1,17 @@ +package com.euonia.osba; + +/** + * Represents a business object that can be executed. This class extends the BusinessObject class and provides default implementations for the execute and create methods, which can be overridden by subclasses to provide specific behavior. + * + * @param the type of the business object, which must extend ExecutableObject + */ +public abstract class ExecutableObject> extends BusinessObject { + + protected void execute() { + // Default implementation does nothing, can be overridden by subclasses + } + + protected void create() { + // Default implementation does nothing, can be overridden by subclasses + } +} diff --git a/osba/src/main/java/com/euonia/osba/ObjectEditState.java b/osba/src/main/java/com/euonia/osba/ObjectEditState.java new file mode 100644 index 0000000..f182e6a --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ObjectEditState.java @@ -0,0 +1,25 @@ +package com.euonia.osba; + +/** + * Represents the edit state of an object, indicating whether it is new, changed, deleted, or unchanged. + * This enum is used to track the state of an object during its lifecycle, allowing for proper handling of changes and deletions in the context of business rules and data persistence. + */ +public enum ObjectEditState { + + /** + * Indicates that the object is unchanged, meaning it has not been modified since it was last saved to the database. + */ + NONE, + /** + * Indicates that the object is new, meaning it has been created but not yet saved to the database. This state is used to track newly created objects that need to be saved. + */ + NEW, + /** + * Indicates that the object has been changed, meaning it has been modified since it was last saved to the database. This state is used to track modified objects that need to be saved. + */ + CHANGED, + /** + * Indicates that the object has been marked as deleted, meaning it has been flagged for deletion but not yet removed from the database. This state is used to track objects that need to be deleted. + */ + DELETED +} diff --git a/osba/src/main/java/com/euonia/osba/ObservableObject.java b/osba/src/main/java/com/euonia/osba/ObservableObject.java new file mode 100644 index 0000000..101a4dc --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ObservableObject.java @@ -0,0 +1,242 @@ +package com.euonia.osba; + +import com.euonia.osba.abstracts.OperableProperty; +import com.euonia.osba.abstracts.TrackableObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.Flow.*; +import java.util.concurrent.SubmissionPublisher; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +/** + * Represents an observable business object that can track its edit state (new, changed, deleted) and manage its busy state. + * This class provides methods to mark the object as new, changed, or deleted, and to check whether the object is busy or savable. + * It also implements property access methods that respect the object's rules and permissions. + * + * @param the type of the observable object + */ +@SuppressWarnings("unused") +public abstract class ObservableObject> extends BusinessObject implements TrackableObject, OperableProperty { + private ObjectEditState state; + private boolean checkObjectRulesOnDelete; + private final AtomicInteger busyCounter = new AtomicInteger(); + private final Object lock = new Object(); + private final Publisher busyPublisher = new SubmissionPublisher<>(); + + /** + * Gets the current edit state of the object, which indicates whether the object is new, changed, or deleted. + * + * @return the current edit state of the object + */ + public final ObjectEditState getState() { + return state; + } + + private void setState(ObjectEditState state) { + this.state = state; + } + + /** + * Checks whether the object is new, which means it has been created but not yet saved to the database. + * + * @return true if the object is new, false otherwise + */ + public final boolean isNew() { + return state == ObjectEditState.NEW; + } + + /** + * Checks whether the object has been changed, which means it has been modified since it was last saved to the database. + * + * @return true if the object has been changed, false otherwise + */ + public final boolean isChanged() { + return state == ObjectEditState.CHANGED; + } + + /** + * Checks whether the object has been marked as deleted, which means it has been flagged for deletion but not yet removed from the database. + * + * @return true if the object has been marked as deleted, false otherwise + */ + public final boolean isDeleted() { + return state == ObjectEditState.DELETED; + } + + /** + * Checks whether object rules should be checked when the object is marked as deleted. + * This flag can be set when marking the object as deleted to indicate whether any business rules should be evaluated before allowing the deletion to proceed. + * + * @return true if object rules should be checked on delete, false otherwise + */ + public final boolean isCheckObjectRulesOnDelete() { + return checkObjectRulesOnDelete; + } + + /** + * Marks the object as new, indicating that it has been created but not yet saved to the database. + * This method sets the object's edit state to NEW, which can be used to track changes and determine whether the object needs to be saved. + */ + public final void markAsNew() { + setState(ObjectEditState.NEW); + } + + /** + * Marks the object as changed, indicating that it has been modified since it was last saved to the database. + * This method sets the object's edit state to CHANGED, which can be used to track changes and determine whether the object needs to be saved. + */ + public final void markAsChanged() { + setState(ObjectEditState.CHANGED); + } + + /** + * Marks the object as deleted, indicating that it has been flagged for deletion but not yet removed from the database. + * This method sets the object's edit state to DELETED, which can be used to track changes and determine whether the object needs to be deleted. + */ + public final void markAsDeleted() { + markAsDeleted(false); + } + + /** + * Marks the object as deleted, indicating that it has been flagged for deletion but not yet removed from the database. + * This method sets the object's edit state to DELETED, which can be used to track changes and determine whether the object needs to be deleted. + * + * @param checkObjectRules whether to check object rules before allowing the deletion to proceed + */ + public final void markAsDeleted(boolean checkObjectRules) { + setState(ObjectEditState.DELETED); + checkObjectRulesOnDelete = checkObjectRules; + } + + /** + * Checks whether the object is currently busy, which means it is performing an operation that prevents it from being saved or modified. + * This method checks both the object's own busy state and the busy state of any related fields or rules to determine whether the object is currently busy. + * + * @return true if the object is busy, false otherwise + */ + public boolean isBusy() { + return isSelfBusy() || (getFieldManager().isBusy()); + } + + /** + * Checks whether the object itself is busy, which means it is performing an operation that prevents it from being saved or modified. + * This method checks the object's own busy state and the busy state of any running rules to determine whether the object is currently busy. + * + * @return true if the object itself is busy, false otherwise + */ + public boolean isSelfBusy() { + return busyCounter.get() > 0 || getRules().hasRunningRules(); + } + + /** + * Checks whether the object is savable, which means it is valid, has changes, and is not busy. + * This method can be used to determine whether the object can be saved to the database. + * + * @return true if the object is savable, false otherwise + */ + public boolean isSavable() { + return isValid() && (hasChangedProperties() || isChanged()) && !isBusy(); + } + + /** + * Marks the object as busy, indicating that it is performing an operation that prevents it from being saved or modified. + * This method increments the busy counter and notifies any listeners if the object becomes busy. + */ + protected void markAsBusy() { + var updateValue = busyCounter.incrementAndGet(); + + if (updateValue == 1) { + ((SubmissionPublisher) busyPublisher).submit(false); + } + } + + /** + * Marks the object as idle, indicating that it is no longer performing an operation that prevents it from being saved or modified. + * This method decrements the busy counter and notifies any listeners if the object becomes idle. + */ + protected void markAsIdle() { + var updateValue = busyCounter.decrementAndGet(); + + if (updateValue == 0) { + ((SubmissionPublisher) busyPublisher).submit(false); + } + } + + /** + * Subscribes a listener to be notified when the busy state of the object changes. + * + * @param listener the listener to be notified + */ + public final void onBusyChanged(Consumer listener) { + busyPublisher.subscribe(new Subscriber<>() { + @Override + public void onSubscribe(Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(Boolean item) { + listener.accept(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }); + } + + // region Get/Set Properties + protected V getProperty(String propertyName, V field, V defaultValue) { + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + return field; + } + return defaultValue; + } + + protected V getProperty(PropertyInfo propertyInfo, V field) { + return getProperty(propertyInfo, field, propertyInfo.getDefaultValue()); + } + + protected V getProperty(PropertyInfo propertyInfo, V field, V defaultValue) { + return getProperty(propertyInfo.getName(), field, defaultValue); + } + + @Override + public V getProperty(PropertyInfo propertyInfo) { + V value; + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + value = readProperty(propertyInfo); + } else { + value = propertyInfo.getDefaultValue(); + } + return value; + } + + @Override + public void setProperty(PropertyInfo propertyInfo, V newValue) { + if (!isBypassingRuleCheck() && !canWriteProperty(propertyInfo, true)) { + return; + } + + V oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, newValue); + } else { + oldValue = fieldData.getValue(); + } + + loadPropertyValue(propertyInfo, oldValue, newValue, !isBypassingRuleCheck()); + } + // endregion + +} diff --git a/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java b/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java new file mode 100644 index 0000000..4380b03 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/ReadOnlyObject.java @@ -0,0 +1,76 @@ +package com.euonia.osba; + +import com.euonia.reflection.PropertyInfo; + +/** + * Represents a read-only business object. + * This class extends the BusinessObject class and is intended for objects that should not be modified after creation. + * It does not provide any additional methods or properties, but serves as a marker to indicate that the object is read-only. + * + * @param the type of the business object, which must extend ReadOnlyObject + */ +public abstract class ReadOnlyObject> extends BusinessObject { + @Override + protected boolean isBypassingRuleCheck() { + return true; + } + + protected V getProperty(String propertyName, V field, V defaultValue) { + + var propertyInfo = getFieldManager().getRegisteredProperty(propertyName); + + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + return field; + } + return defaultValue; + } + + protected V getProperty(PropertyInfo propertyInfo, V field) { + return getProperty(propertyInfo.getName(), field, propertyInfo.getDefaultValue()); + } + + protected V getProperty(PropertyInfo propertyInfo, V field, V defaultValue) { + return getProperty(propertyInfo.getName(), field, defaultValue); + } + + /** + * Gets the value of a property. If the object is not bypassing rule checks and the property cannot be read, the method will return the default value for the property. + * + * @param propertyInfo the property information + * @param the type of the property value + * @return the value of the property + */ + protected V getProperty(PropertyInfo propertyInfo) { + V value; + if (isBypassingRuleCheck() || canReadProperty(propertyInfo, true)) { + value = readProperty(propertyInfo); + } else { + value = propertyInfo.getDefaultValue(); + } + return value; + } + + /** + * Sets the value of a property. If the object is not bypassing rule checks and the property cannot be written to, the method will return without making any changes. + * + * @param propertyInfo the property information + * @param newValue the new value to set + * @param the type of the property value + */ + protected void setProperty(PropertyInfo propertyInfo, V newValue) { + if (!isBypassingRuleCheck() && !canWriteProperty(propertyInfo, true)) { + return; + } + + V oldValue; + var fieldData = getFieldManager().getFieldData(propertyInfo); + if (fieldData == null) { + oldValue = propertyInfo.getDefaultValue(); + getFieldManager().loadFieldData(propertyInfo, newValue); + } else { + oldValue = fieldData.getValue(); + } + + loadPropertyValue(propertyInfo, oldValue, newValue, !isBypassingRuleCheck()); + } +} diff --git a/osba/src/main/java/com/euonia/osba/SavedEventArgs.java b/osba/src/main/java/com/euonia/osba/SavedEventArgs.java new file mode 100644 index 0000000..c51e5b6 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/SavedEventArgs.java @@ -0,0 +1,29 @@ +package com.euonia.osba; + +public class SavedEventArgs { + private final Object newObject; + private Object userState; + private Throwable error; + + public SavedEventArgs(Object newObject) { + this.newObject = newObject; + } + + public SavedEventArgs(Object newObject, Throwable error, Object userState) { + this.newObject = newObject; + this.error = error; + this.userState = userState; + } + + public Object getNewObject() { + return newObject; + } + + public Throwable getError() { + return error; + } + + public Object getUserState() { + return userState; + } +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java new file mode 100644 index 0000000..a51f6c0 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/OperableProperty.java @@ -0,0 +1,26 @@ +package com.euonia.osba.abstracts; + +import com.euonia.reflection.PropertyInfo; + +/** + * Represents a contract for an object that can get and set properties. + */ +public interface OperableProperty { + /** + * Gets the value of a property based on the provided PropertyInfo. + * + * @param propertyInfo The PropertyInfo object representing the property to retrieve. + * @param The type of the property value. + * @return The value of the property. + */ + V getProperty(PropertyInfo propertyInfo); + + /** + * Sets the value of a property based on the provided PropertyInfo and value. + * + * @param propertyInfo The PropertyInfo object representing the property to set. + * @param value The value to set for the property. + * @param The type of the property value. + */ + void setProperty(PropertyInfo propertyInfo, V value); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java b/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java new file mode 100644 index 0000000..3d054ce --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/RuleCheckable.java @@ -0,0 +1,45 @@ +package com.euonia.osba.abstracts; + +import com.euonia.osba.rules.BrokenRuleCollection; +import com.euonia.reflection.PropertyInfo; + +/** + * Represents that the implemented class have rule checking. + */ +public interface RuleCheckable { + + /** + * Indicates whether the object is valid based on the current broken rules. + * + * @return true if the object is valid, false otherwise + */ + boolean isValid(); + + void ruleCheckComplete(PropertyInfo property); + + void ruleCheckComplete(String property); + + void allRulesComplete(); + + /** + * Suspends rule checking for the object. + * While rule checking is suspended, changes to properties will not trigger rule evaluations or updates to the broken rules collection. + * This can be useful when performing multiple updates to an object, + * and you want to avoid intermediate rule checks until all changes are made. + */ + void suspendRuleChecking(); + + /** + * Resumes rule checking for the object after it has been suspended. + * When rule checking is resumed, any changes made to properties while it was suspended will trigger rule evaluations, and the broken rules collection will be updated accordingly. + * This allows you to ensure that the object's validity is accurately reflected after making multiple changes while rule checking was suspended. + */ + void resumeRuleChecking(); + + /** + * Gets the collection of broken rules for the object. + * + * @return the collection of broken rules + */ + BrokenRuleCollection getBrokenRules(); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/Savable.java b/osba/src/main/java/com/euonia/osba/abstracts/Savable.java new file mode 100644 index 0000000..a97ea0d --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/Savable.java @@ -0,0 +1,12 @@ +package com.euonia.osba.abstracts; + +/** + * Marks an object that can be saved to a database or other persistent storage. + * + * @param The type of the object being saved. + */ +public interface Savable { + void saveComplete(T newObject); + + T save(boolean forceUpdate); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java new file mode 100644 index 0000000..19945ba --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/TrackableObject.java @@ -0,0 +1,73 @@ +package com.euonia.osba.abstracts; + +/** + * TrackableObject is an interface that defines methods for tracking the state + * of an object in terms of its validity, changes, deletion status, and whether + * it is new or can be saved. This interface is typically used in business + * objects to manage their lifecycle and ensure that they are in a consistent + * state before performing operations such as saving or deleting. + *

+ * The methods defined in this interface include: + *

+ * - isValid(): Indicates whether the object is in a valid state. This is used + * to determine whether the object can be saved or not. + *

+ * - isChanged(): Indicates whether the object has been changed since it was + * last saved. + *

+ * - isDeleted(): Indicates whether the object has been marked for deletion. + *

+ * - isNew(): Indicates whether the object is new and has not been saved yet. + *

+ * - isSavable(): Indicates whether the object can be saved. + *

+ * Implementing this interface allows business objects to manage their state + * effectively and ensure that operations are performed only when the object is + * in an appropriate state. For example, an object that is not valid should + * not be saved, and an object that is marked for deletion should not be updated. + */ +public interface TrackableObject { + + /** + * Indicates whether the object is in a valid state. This is used to determine + * whether the object can be saved or not. + * + * @return true if the object is valid, false otherwise + */ + boolean isValid(); + + /** + * Indicates whether the object has been changed since it was last saved. + * + * @return true if the object has been changed, false otherwise + */ + boolean isChanged(); + + /** + * Indicates whether the object has been marked for deletion. + * + * @return true if the object is marked for deletion, false otherwise + */ + boolean isDeleted(); + + /** + * Indicates whether the object is new and has not been saved yet. + * + * @return true if the object is new, false otherwise + */ + boolean isNew(); + + /** + * Indicates whether the object can be saved. + * + * @return true if the object can be saved, false otherwise + */ + boolean isSavable(); + + /** + * Indicates whether the object is currently busy, which means that it is performing an operation that should not be interrupted, such as saving or deleting. + * + * @return true if the object is busy, false otherwise + */ + boolean isBusy(); +} diff --git a/osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java b/osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java new file mode 100644 index 0000000..8931bda --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/abstracts/UseBusinessContext.java @@ -0,0 +1,12 @@ +package com.euonia.osba.abstracts; + +import com.euonia.osba.BusinessContext; + +/** + * Marks an object that participates in OSBA business-context propagation. + */ +public interface UseBusinessContext { + BusinessContext getBusinessContext(); + + void setBusinessContext(BusinessContext businessContext); +} diff --git a/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java b/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java new file mode 100644 index 0000000..14dcf06 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/BrokenRule.java @@ -0,0 +1,29 @@ +package com.euonia.osba.rules; + +/** + * Represents a broken rule that has been violated during rule checking. + * It contains information about the property that violated the rule, a description of the violation, and the severity of the violation. + */ +public final class BrokenRule { + private final String property; + private final String description; + private final RuleSeverity severity; + + public BrokenRule(String property, String description, RuleSeverity severity) { + this.property = property; + this.description = description; + this.severity = severity; + } + + public String getProperty() { + return property; + } + + public String getDescription() { + return description; + } + + public RuleSeverity getSeverity() { + return severity; + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java new file mode 100644 index 0000000..e0b016e --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/BrokenRuleCollection.java @@ -0,0 +1,101 @@ +package com.euonia.osba.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; + +/** + * Represents a collection of broken rules that have been violated during rule checking. + * It provides methods to manage the collection of broken rules, including adding new broken rules, clearing existing rules, and retrieving counts of broken rules based on their severity. + */ +public final class BrokenRuleCollection { + private final List rules = new ArrayList<>(); + + /** + * Clears all broken rules from the collection, regardless of their associated property. + */ + public synchronized void clearRules() { + rules.clear(); + } + + /** + * Clears broken rules from the collection that are associated with the specified property name. + * + * @param propertyName the name of the property whose associated broken rules should be cleared + */ + public synchronized void clearRules(String propertyName) { + rules.removeIf(rule -> propertyName == null + ? rule.getProperty() == null + : propertyName.equals(rule.getProperty())); + } + + /** + * Adds new broken rules to the collection based on the provided list of rule results and the associated property name. + * + * @param results the list of rule results to be added as broken rules + * @param propertyName the name of the property associated with the broken rules + */ + public synchronized void add(List results, String propertyName) { + for (var result : results) { + if (result.isSuccess()) { + continue; + } + rules.add(new BrokenRule(propertyName, result.getDescription(), result.getSeverity())); + } + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of ERROR. + * + * @return the count of broken rules with severity ERROR + */ + public synchronized int getErrorCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.ERROR).count(); + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of WARNING. + * + * @return the count of broken rules with severity WARNING + */ + public synchronized int getWarningCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.WARNING).count(); + } + + /** + * Retrieves the count of broken rules in the collection that have a severity level of INFORMATION. + * + * @return the count of broken rules with severity INFORMATION + */ + public synchronized int getInformationCount() { + return (int) rules.stream().filter(rule -> rule.getSeverity() == RuleSeverity.INFORMATION).count(); + } + + /** + * Checks if the collection of broken rules is empty. + * + * @return true if the collection is empty, false otherwise + */ + public synchronized boolean isEmpty() { + return rules.isEmpty(); + } + + /** + * Returns an unmodifiable list of broken rules in the collection. + * + * @return an unmodifiable list of broken rules + */ + public synchronized List list() { + return List.copyOf(rules); + } + + /** + * Returns a stream of broken rules in the collection. + * + * @return a stream of broken rules + */ + public synchronized Stream stream() { + return new ArrayList<>(rules).stream(); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java b/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java new file mode 100644 index 0000000..809ae2c --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/DataAnnotationRule.java @@ -0,0 +1,48 @@ +package com.euonia.osba.rules; + +import com.euonia.annotation.Validation; +import com.euonia.annotation.Validator; +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; +import com.euonia.tuple.Duet; + +import java.lang.annotation.Annotation; +import java.util.concurrent.CompletableFuture; + +public class DataAnnotationRule extends RuleBase { + + private final A annotation; + + public DataAnnotationRule(PropertyInfo property, A annotation) { + super(property); + assert annotation != null : "Annotation cannot be null."; + this.annotation = annotation; + } + + @SuppressWarnings("unchecked") + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + var value = businessObject.readProperty(getProperty()); + + var field = getProperty().getField(); + if (field != null) { + var validatorAnnotation = annotation.annotationType().getAnnotation(Validation.class); + if (validatorAnnotation != null) { + var validatorClass = validatorAnnotation.validator(); + var validator = validatorClass.getDeclaredConstructor().newInstance(); + Duet validate = ((Validator) validator).validate(annotation, value); + if (!validate.value1()) { + context.addErrorResult(validate.value2()); + } + } + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java new file mode 100644 index 0000000..17cbf81 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/LambdaRule.java @@ -0,0 +1,47 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.BiFunction; +import java.util.function.Function; + +/** + * LambdaRule allows defining a validation rule using a lambda function, providing flexibility to implement custom validation logic for a specific property of a business object. + * + * @param the type of the property value being validated + */ +public class LambdaRule extends RuleBase { + private final PropertyInfo property; + private final BiFunction function; + private final Function messageFactory; + + public LambdaRule(PropertyInfo property, BiFunction function, String message) { + this(property, function, x -> message); + } + + public LambdaRule(PropertyInfo property, BiFunction function, Function messageFactory) { + super(property); + this.property = property; + this.function = function; + this.messageFactory = messageFactory; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + T value = businessObject.readProperty(property); + + if (!function.apply(value, context)) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RegularRule.java b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java new file mode 100644 index 0000000..f675ae3 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RegularRule.java @@ -0,0 +1,62 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public class RegularRule extends RuleBase { + + private final PropertyInfo property; + private final Function messageFactory; + private final String expression; + + private boolean ignoreNullValue = true; + + public RegularRule(PropertyInfo property, String expression, String message) { + this(property, expression, x -> message); + } + + public RegularRule(PropertyInfo property, String expression, Function messageFactory) { + super(property); + this.property = property; + this.expression = expression; + this.messageFactory = messageFactory; + } + + public String getExpression() { + return expression; + } + + public boolean isIgnoreNullValue() { + return ignoreNullValue; + } + + public void setIgnoreNullValue(boolean ignoreNullValue) { + this.ignoreNullValue = ignoreNullValue; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + String value = businessObject.readProperty(property); + + if (value == null || value.isEmpty()) { + if (ignoreNullValue) { + context.addErrorResult(messageFactory.apply(value)); + } + } else { + if (!value.matches(expression)) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java new file mode 100644 index 0000000..1f533f1 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RequiredRule.java @@ -0,0 +1,40 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.BusinessObject; +import com.euonia.reflection.PropertyInfo; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; + +public class RequiredRule extends RuleBase { + + private final PropertyInfo property; + private final Function messageFactory; + + public RequiredRule(PropertyInfo property, String message) { + this(property, x -> message); + } + + public RequiredRule(PropertyInfo property, Function messageFactory) { + super(property); + this.property = property; + this.messageFactory = messageFactory; + } + + @Override + public CompletableFuture executeAsync(RuleContext context) { + try { + if (context.getTarget() instanceof BusinessObject businessObject) { + + Object value = businessObject.readProperty(property); + + if (value == null || (value instanceof String string && string.isEmpty())) { + context.addErrorResult(messageFactory.apply(value)); + } + } + } catch (Exception exception) { + context.addErrorResult(exception.getMessage()); + } + return CompletableFuture.completedFuture(null); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/Rule.java b/osba/src/main/java/com/euonia/osba/rules/Rule.java new file mode 100644 index 0000000..2bfc724 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/Rule.java @@ -0,0 +1,20 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.util.List; +import java.util.concurrent.CompletableFuture; + +public interface Rule { + String getName(); + + PropertyInfo getProperty(); + + default List> getRelatedProperties() { + return List.of(); + } + + int getPriority(); + + CompletableFuture executeAsync(RuleContext context); +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleBase.java b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java new file mode 100644 index 0000000..f5ed5bb --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleBase.java @@ -0,0 +1,58 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.lang.reflect.Member; +import java.lang.reflect.Type; + +/** + * Provides a base implementation for rules, including common properties and methods. + * This class can be extended to create specific rule implementations. + */ +public abstract class RuleBase implements Rule { + private static final String NAME_PREFIX = "rule://"; + + private final String name; + + private PropertyInfo property; + + protected RuleBase() { + name = generateName(getClass()); + } + + protected RuleBase(PropertyInfo property) { + name = generateName(getClass(), property.getName()); + this.property = property; + } + + protected RuleBase(PropertyInfo property, Member member) { + name = generateName(getClass(), property.getName(), member.getName()); + this.property = property; + } + + public String getName() { + return name; + } + + public final PropertyInfo getProperty() { + return property; + } + + @Override + public int getPriority() { + return 0; + } + + private static String generateName(Type type, String... names) { + var typeName = type.getTypeName(); + return generateName(typeName, names); + } + + private static String generateName(String typeName, String... names) { + var builder = new StringBuilder(NAME_PREFIX + typeName); + for (var name : names) { + builder.append("/").append(name); + } + return builder.toString(); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java new file mode 100644 index 0000000..c0a8818 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleCheckException.java @@ -0,0 +1,24 @@ +package com.euonia.osba.rules; + +import com.euonia.http.ResponseHttpStatusCode; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@ResponseHttpStatusCode(400) +public class RuleCheckException extends RuntimeException { + + private final Map> errors = new HashMap<>(); + + private final static String MESSAGE = "Object not valid for save."; + + public RuleCheckException(Map> errors) { + super(MESSAGE); + this.errors.putAll(errors); + } + + public Map> getErrors() { + return Map.copyOf(errors); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleContext.java b/osba/src/main/java/com/euonia/osba/rules/RuleContext.java new file mode 100644 index 0000000..640bb94 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleContext.java @@ -0,0 +1,71 @@ +package com.euonia.osba.rules; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; + +public class RuleContext { + private final List results = new ArrayList<>(); + private final Consumer completeAction; + private Rule rule; + private Object target; + private String propertyName; + + public RuleContext(Consumer completeAction) { + this.completeAction = completeAction; + } + + public Rule getRule() { + return rule; + } + + public void setRule(Rule rule) { + this.rule = rule; + } + + public Object getTarget() { + return target; + } + + public void setTarget(Object target) { + this.target = target; + } + + public String getPropertyName() { + return propertyName; + } + + public void setPropertyName(String propertyName) { + this.propertyName = propertyName; + } + + public List getResults() { + return Collections.unmodifiableList(results); + } + + public void addErrorResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.ERROR)); + } + + public void addWarningResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.WARNING)); + } + + public void addInformationResult(String description) { + results.add(new RuleResult(rule.getName(), description, RuleSeverity.INFORMATION)); + } + + public void addSuccessResult() { + results.add(new RuleResult(rule.getName())); + } + + public void complete() { + if (results.isEmpty()) { + addSuccessResult(); + } + if (completeAction != null) { + completeAction.accept(this); + } + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleManager.java b/osba/src/main/java/com/euonia/osba/rules/RuleManager.java new file mode 100644 index 0000000..7af2f96 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleManager.java @@ -0,0 +1,66 @@ +package com.euonia.osba.rules; + +import java.util.List; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.CopyOnWriteArrayList; + +/** + * Manages the rules associated with a specific type. It provides methods to retrieve and manage the rules for that type. + * The RuleManager is designed to be thread-safe, allowing concurrent access and modifications to the rules without the need for external synchronization. + * It uses a ConcurrentMap to store RuleManager instances for different types, and a CopyOnWriteArrayList to store the rules for each type, ensuring that modifications to the rules do not affect concurrent reads. + */ +public final class RuleManager { + private static final ConcurrentMap, RuleManager> ruleSets = new java.util.concurrent.ConcurrentHashMap<>(); + + private final List rules = new CopyOnWriteArrayList<>(); + + private RuleManager() { + } + + public List getRules() { + return rules; + } + + /** + * Retrieves the RuleManager instance associated with the specified type. If no RuleManager exists for the type, a new instance is created and stored in the ruleSets map. + * + * @param type the class type whose RuleManager is to be retrieved + * @return the RuleManager instance associated with the specified type + */ + public static RuleManager getRules(Class type) { + return ruleSets.computeIfAbsent(type, c -> new RuleManager()); + } + + /** + * Removes the RuleManager associated with the specified type from the ruleSets map, effectively clearing all rules for that type. + * + * @param type the class type whose rules are to be cleared + */ + public static void cleanRules(Class type) { + synchronized (ruleSets) { + ruleSets.remove(type); + } + } + + private boolean initialized = false; + + /** + * Indicates whether the RuleManager has been initialized with rules for the associated type. + * This property can be used to determine if the rules have been set up and are ready for use. + * + * @return true if the RuleManager has been initialized, false otherwise + */ + public boolean isInitialized() { + return initialized; + } + + /** + * Sets the initialized state of the RuleManager. + * This method can be used to mark the RuleManager as initialized after rules have been added for the associated type. + * + * @param initialized true to mark the RuleManager as initialized, false otherwise + */ + public void setInitialized(boolean initialized) { + this.initialized = initialized; + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleResult.java b/osba/src/main/java/com/euonia/osba/rules/RuleResult.java new file mode 100644 index 0000000..83fceba --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleResult.java @@ -0,0 +1,90 @@ +package com.euonia.osba.rules; + +import com.euonia.reflection.PropertyInfo; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Represents the result of a rule execution, including whether the rule passed or failed, + * a description of the result, the severity of the result, and the name of the rule that was executed. + */ +public final class RuleResult { + private final boolean success; + private final String description; + private final RuleSeverity severity; + private final String ruleName; + private final List> properties = new ArrayList>(); + + public RuleResult(String ruleName) { + this(ruleName, null, RuleSeverity.SUCCESS); + } + + public RuleResult(String ruleName, String description, RuleSeverity severity) { + this.ruleName = ruleName; + this.description = description; + this.severity = severity == null ? RuleSeverity.SUCCESS : severity; + this.success = description == null || description.isBlank(); + } + + /** + * Indicates whether the rule execution was successful. + * A rule is considered successful if there is no description of a failure (i.e., the description is null or blank). + * + * @return true if the rule execution was successful, false otherwise + */ + public boolean isSuccess() { + return success; + } + + /** + * Gets the description of the rule execution result. If the rule execution was successful, this may be null or blank. + * If the rule execution failed, this should contain a description of the failure. + * + * @return the description of the rule execution result + */ + public String getDescription() { + return description; + } + + /** + * Gets the severity of the rule execution result. + * The severity indicates the level of importance or impact of the rule execution result, such as success, warning, or error. + * + * @return the severity of the rule execution result + */ + public RuleSeverity getSeverity() { + return severity; + } + + /** + * Gets the name of the rule that was executed to produce this result. + * + * @return the name of the rule that was executed + */ + public String getRuleName() { + return ruleName; + } + + /** + * Gets the list of properties that are related to this rule result. + * This can be used to identify which properties were affected by the rule execution. + * + * @return the list of properties related to this rule result + */ + public List> getProperties() { + return Collections.unmodifiableList(properties); + } + + /** + * Sets the list of properties that are related to this rule result. + * This can be used to identify which properties were affected by the rule execution. + * + * @param properties the list of properties to set + */ + public void setProperties(List> properties) { + this.properties.clear(); + this.properties.addAll(properties); + } +} diff --git a/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java b/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java new file mode 100644 index 0000000..f2d91c7 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/RuleSeverity.java @@ -0,0 +1,28 @@ +package com.euonia.osba.rules; + +/** + * Defines the severity levels for rules in the OSBA framework. + * These levels indicate the importance of a rule violation and can be used to categorize and prioritize issues found during rule evaluation. + */ +public enum RuleSeverity { + /** + * Indicates a critical issue that must be addressed immediately. + * This level is used for violations that could lead to significant problems or failures if not resolved. + */ + ERROR, + /** + * Indicates a warning that should be addressed but is not critical. + * This level is used for violations that could lead to potential issues if not resolved. + */ + WARNING, + /** + * Indicates informational messages that do not require immediate action. + * This level is used for violations that provide insights or recommendations. + */ + INFORMATION, + /** + * Indicates a successful operation or validation. + * This level is used for violations that confirm correct behavior or compliance. + */ + SUCCESS +} diff --git a/osba/src/main/java/com/euonia/osba/rules/Rules.java b/osba/src/main/java/com/euonia/osba/rules/Rules.java new file mode 100644 index 0000000..85daab1 --- /dev/null +++ b/osba/src/main/java/com/euonia/osba/rules/Rules.java @@ -0,0 +1,204 @@ +package com.euonia.osba.rules; + +import com.euonia.osba.abstracts.RuleCheckable; +import com.euonia.reflection.PropertyInfo; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.Consumer; + +/** + * Rules manages the execution of validation rules for a business object, tracking broken rules and notifying listeners when validation is complete. + */ +public final class Rules { + private final RuleCheckable target; + private final List> validationCompleteListeners = new CopyOnWriteArrayList<>(); + private final BrokenRuleCollection brokenRules = new BrokenRuleCollection(); + private final List runningRules = new CopyOnWriteArrayList<>(); + + private volatile boolean suppressRuleChecking; + private volatile boolean hasRunningRules; + + public Rules(RuleCheckable target) { + this.target = target; + } + + private volatile RuleManager ruleManager; + + /** + * Gets the RuleManager for the target object, initializing it if necessary. + * + * @return the RuleManager for the target object + */ + public RuleManager getRuleManager() { + if (ruleManager == null) { + synchronized (this) { + if (ruleManager == null) { + ruleManager = RuleManager.getRules(target.getClass()); + } + } + } + return ruleManager; + } + + /** + * Adds a validation rule to the target object's RuleManager. + * + * @param rule the validation rule to add + */ + public void addRule(Rule rule) { + if (rule != null) { + getRuleManager().getRules().add(rule); + } + } + + /** + * Gets the collection of broken rules that have been identified during rule checking. + * + * @return the collection of broken rules + */ + public BrokenRuleCollection getBrokenRules() { + return brokenRules; + } + + /** + * Determines whether the target object is valid based on the current collection of broken rules. + * + * @return true if the target object is valid, false otherwise + */ + public boolean isValid() { + return brokenRules.getErrorCount() == 0; + } + + /** + * Checks whether there are currently any rules running for the target object. + * + * @return true if there are rules running, false otherwise + */ + public boolean hasRunningRules() { + return hasRunningRules; + } + + /** + * Gets whether rule checking is currently suppressed for the target object. + * When rule checking is suppressed, no rules will be executed and the object will be considered valid. + * + * @return true if rule checking is suppressed, false otherwise + */ + public boolean isSuppressRuleChecking() { + return suppressRuleChecking; + } + + /** + * Sets whether rule checking is currently suppressed for the target object. + * When rule checking is suppressed, no rules will be executed and the object will be considered valid. + * + * @param suppressRuleChecking true to suppress rule checking, false to enable it + */ + public void setSuppressRuleChecking(boolean suppressRuleChecking) { + this.suppressRuleChecking = suppressRuleChecking; + } + + /** + * Adds a listener to be notified when validation is complete. + * The listener will receive the collection of broken rules as an argument. + * + * @param listener the listener to be added + */ + public void addValidationCompleteListener(Consumer listener) { + if (listener != null) { + validationCompleteListeners.add(listener); + } + } + + /** + * Removes a listener from being notified when validation is complete. + * + * @param listener the listener to be removed + */ + public void removeValidationCompleteListener(Consumer listener) { + validationCompleteListeners.remove(listener); + } + + /** + * Asynchronously checks all validation rules for the target object and updates the collection of broken rules accordingly. + * The method returns a CompletableFuture that completes when all rules have been checked, providing a list of affected property names. + * + * @return a CompletableFuture that completes with a list of affected property names + */ + public CompletableFuture> checkObjectRulesAsync() { + if (suppressRuleChecking || getRuleManager().getRules().isEmpty()) { + return CompletableFuture.completedFuture(List.of()); + } + + hasRunningRules = true; + brokenRules.clearRules(); + List affectedProperties = new ArrayList<>(); + List> tasks = getRuleManager().getRules().stream() + .sorted(Comparator.comparingInt(Rule::getPriority)) + .map(rule -> runRule(rule, affectedProperties)) + .toList(); + + return CompletableFuture.allOf(tasks.toArray(CompletableFuture[]::new)) + .thenApply(ignored -> affectedProperties.stream().distinct().toList()) + .whenComplete((ignored, error) -> { + hasRunningRules = false; + notifyValidationComplete(); + }); + } + + private CompletableFuture runRule(Rule rule, List affectedProperties) { + RuleContext context = new RuleContext(ctx -> { + synchronized (this) { + getBrokenRules().add(ctx.getResults(), ctx.getRule().getProperty().getName()); + runningRules.remove(ctx.getRule()); + + var properties = new ArrayList>(); + + if (ctx.getRule().getProperty() != null) { + properties.add(ctx.getRule().getProperty()); + } + + if (ctx.getRule().getRelatedProperties() != null) { + properties.addAll(ctx.getRule().getRelatedProperties()); + } + + for (var property : properties) { + if (runningRules.stream().noneMatch(r -> r.getName().equals(property.getName()))) { + target.ruleCheckComplete(property); + } + } + + if (!hasRunningRules) { + target.allRulesComplete(); + } + } + }) {{ + setRule(rule); + setTarget(target); + if(rule.getProperty() == null){ + System.out.println(rule.getName()); + } + setPropertyName(rule.getProperty().getName()); + }}; + String propertyName = rule.getProperty().getName(); + if (propertyName != null) { + brokenRules.clearRules(propertyName); + affectedProperties.add(propertyName); + } + affectedProperties.addAll(rule.getRelatedProperties().stream().map(PropertyInfo::getName).toList()); + + runningRules.add(rule); + return rule.executeAsync(context) + .thenRun(context::complete); + } + + private void notifyValidationComplete() { + for (var listener : validationCompleteListeners) { + listener.accept(brokenRules); + } + } +} diff --git a/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java b/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java new file mode 100644 index 0000000..fbf23d7 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/AmbiguousMethodException.java @@ -0,0 +1,17 @@ +package com.euonia.reflection; + +/** + * AmbiguousMethodException is a custom exception that is thrown when multiple + * methods match the criteria for a factory method, making it unclear which + * method should be used. + * It extends RuntimeException and provides a message that describes the + * ambiguity in the method selection process. + * This exception is typically thrown when there are multiple methods with the + * same name or annotation that could potentially be used as a factory method, + * and the criteria provided does not allow for a clear distinction between them. + */ +public class AmbiguousMethodException extends RuntimeException { + public AmbiguousMethodException(String message) { + super(message); + } +} diff --git a/osba/src/main/java/com/euonia/reflection/FieldData.java b/osba/src/main/java/com/euonia/reflection/FieldData.java new file mode 100644 index 0000000..c78c560 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/FieldData.java @@ -0,0 +1,128 @@ +package com.euonia.reflection; + +import java.util.Objects; +import java.util.Stack; +import java.util.concurrent.Flow; +import java.util.concurrent.SubmissionPublisher; + +import com.euonia.osba.abstracts.TrackableObject; + +/** + * Represents a field of an object that can be tracked for changes. + * This class provides methods to get and set the value of the field, as well as to mark it as unchanged or undo changes. + * + * @param the type of the field value. + */ +public class FieldData implements TrackableObject { + private final Stack history = new Stack<>(); + private final Flow.Publisher publisher = new SubmissionPublisher<>(); + + private String name; + private T value; + + public FieldData() { + Flow.Subscriber subscriber = new Flow.Subscriber<>() { + @Override + public void onSubscribe(Flow.Subscription subscription) { + subscription.request(Long.MAX_VALUE); + } + + @Override + public void onNext(T item) { + history.push(item); + } + + @Override + public void onError(Throwable throwable) { + + } + + @Override + public void onComplete() { + + } + }; + publisher.subscribe(subscriber); + } + + public FieldData(String name) { + this(); + this.name = name; + } + + public String getName() { + return name; + } + + public T getValue() { + return value; + } + + public void setValue(T value) { + if (this.value == null && value == null) { + return; + } + + if (Objects.equals(this.value, value)) { + return; + } + this.value = value; + ((SubmissionPublisher) publisher).submit(this.value); + } + + public void markAsUnchanged() { + history.clear(); + } + + public void undo() { + if (!history.isEmpty()) { + this.value = history.pop(); + } + } + + @Override + public boolean isChanged() { + return !history.isEmpty(); + } + + @Override + public boolean isDeleted() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isDeleted(); + } + return false; + } + + @Override + public boolean isNew() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isNew(); + } + return false; + } + + @Override + public boolean isSavable() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isSavable(); + } + return false; + } + + @Override + public boolean isValid() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isValid(); + } + return false; + } + + + @Override + public boolean isBusy() { + if (value instanceof TrackableObject trackableObject) { + return trackableObject.isBusy(); + } + return false; + } +} diff --git a/osba/src/main/java/com/euonia/reflection/FieldDataManager.java b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java new file mode 100644 index 0000000..cff35d7 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/FieldDataManager.java @@ -0,0 +1,146 @@ +package com.euonia.reflection; + +import com.euonia.osba.BusinessObject; + +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@SuppressWarnings("unused") +public class FieldDataManager { + private final Map> fieldData = new HashMap<>(); + private final List> properties; + + public FieldDataManager(Class businessObjectType) { + properties = createConsolidatedList(businessObjectType); + } + + private static List> createConsolidatedList(Class type) { + var result = new ArrayList>(); + + var currentType = type; + var hierarchy = new ArrayList(); + do { + hierarchy.add(currentType); + currentType = currentType.getSuperclass(); + } + while (currentType != null && !(BusinessObject.class.isAssignableFrom(currentType))); + + for (var index = hierarchy.size() - 1; index >= 0; index--) { + var source = PropertyInfoManager.getPropertyListCache(hierarchy.get(index)); + source.lock(); + result.addAll(source); + } + + return result; + } + + public List> getRegisteredProperties() { + return properties; + } + + public PropertyInfo getRegisteredProperty(String propertyName) { + return getRegisteredProperties().stream() + .filter(p -> p.getName().equals(propertyName)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Property '" + propertyName + "' is not registered.")); + } + + public FieldData getFieldData(String fieldName) { +// if (!fieldData.containsKey(fieldName)) { +// throw new IllegalArgumentException(String.format("Field '%s' is not registered.", fieldName)); +// } + + return fieldData.getOrDefault(fieldName, null); + } + + @SuppressWarnings("unchecked") + public FieldData getFieldData(PropertyInfo property) { + return (FieldData) getFieldData(property.getName()); + } + + @SuppressWarnings("unchecked") + public synchronized FieldData getOrCreateFieldData(PropertyInfo property) { + if (!fieldData.containsKey(property.getName())) { + var field = property.newFieldData(property.getName()); + fieldData.put(property.getName(), field); + } + return (FieldData) fieldData.get(property.getName()); + } + + public void setFieldData(PropertyInfo property, T value) { + FieldData field = getOrCreateFieldData(property); + field.setValue(value); + } + + /** + * Loads the field data for the specified property and value, marking it as unchanged. + * + * @param property the property for which to load the field data + * @param value the value to set for the field data + * @param the type of the property + * @return the loaded field data + */ + @SuppressWarnings("UnusedReturnValue") + public FieldData loadFieldData(PropertyInfo property, T value) { + var field = getOrCreateFieldData(property); + field.setValue(value); + field.markAsUnchanged(); + return field; + } + + /** + * Removes the field data associated with the specified property, if it exists. + * + * @param property the property for which to remove the field data + */ + public void removeFieldData(PropertyInfo property) { + fieldData.remove(property.getName()); + } + + /** + * Checks if field data exists for the specified field name. + * + * @param fieldName the name of the field to check + * @return true if field data exists for the specified field name, false otherwise + */ + public boolean fieldExists(String fieldName) { + return fieldData.containsKey(fieldName); + } + + /** + * Checks if field data exists for the specified property. + * + * @param property the property to check + * @return true if field data exists for the specified property, false otherwise + */ + public boolean fieldExists(PropertyInfo property) { + return fieldExists(property.getName()); + } + + /** + * Initializes the static fields of the specified business object type to ensure that all static properties are registered. + * + * @param businessObjectType the business object type for which to initialize static fields + */ + @SuppressWarnings("ResultOfMethodCallIgnored") + public synchronized static void initStaticFields(Class businessObjectType) { + var fields = businessObjectType.getDeclaredFields(); + for (var field : fields) { + if (field.getModifiers() == java.lang.reflect.Modifier.STATIC) { + field.setAccessible(true); + try { + field.get(null); + } catch (IllegalAccessException e) { + // + } + } + } + } + + public boolean isBusy() { + return fieldData.values().stream().anyMatch(FieldData::isBusy); + } +} diff --git a/osba/src/main/java/com/euonia/reflection/MissingMethodException.java b/osba/src/main/java/com/euonia/reflection/MissingMethodException.java new file mode 100644 index 0000000..685fda2 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/MissingMethodException.java @@ -0,0 +1,49 @@ +package com.euonia.reflection; + +/** + * MissingMethodException is a custom exception that is thrown when a method + * with a specific name or annotation is not found in a given class. + * It extends RuntimeException and provides additional information about the + * type and method that were not found. + * The exception message includes the type name and the method name or + * annotation that was expected but not found. + */ +public class MissingMethodException extends RuntimeException { + + private final String typeName; + private final String methodName; + + /** + * Constructs a new MissingMethodException with the specified type name and + * method name. + * + * @param typeName The name of the class where the method was expected to be + * found. + * @param methodName The name of the method or annotation that was expected but + * not found. + */ + public MissingMethodException(String typeName, String methodName) { + super("No method named " + methodName + " or annotated with convention found in " + typeName); + this.typeName = typeName; + this.methodName = methodName; + } + + /** + * Returns the name of the class where the method was expected to be found. + * + * @return The name of the class. + */ + public String getTypeName() { + return typeName; + } + + /** + * Returns the name of the method or annotation that was expected but not found. + * + * @return The name of the method or annotation. + */ + public String getMethodName() { + return methodName; + } + +} diff --git a/osba/src/main/java/com/euonia/reflection/ObjectReflector.java b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java new file mode 100644 index 0000000..73e38a5 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/ObjectReflector.java @@ -0,0 +1,232 @@ +package com.euonia.reflection; + +import java.lang.annotation.Annotation; +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.util.*; +import java.util.concurrent.ConcurrentMap; + +public class ObjectReflector { + + private static final ConcurrentMap factoryMethodCache = new java.util.concurrent.ConcurrentHashMap<>(); + + public static Method findFactoryMethod(Class targetType, Class annotationType, + Object[] criteria) { + var name = getMethodName(targetType, annotationType, criteria); + return factoryMethodCache.computeIfAbsent(name, s -> findMatchedMethod(targetType, annotationType, criteria)); + } + + private static String getMethodName(Class targetType, Class annotationType, + Object[] criteria) { + var typeName = targetType.getName(); + var annotationName = annotationType.getSimpleName(); + var parameterNames = getParameterTypeName(criteria); + return typeName + "." + annotationName + "(" + parameterNames + ")"; + } + + @SuppressWarnings("SequencedCollectionMethodCanBeUsed") + private static Method findMatchedMethod(Class targetType, Class annotationType, + Object[] criteria) { + var candidates = getCandidateMethods(targetType, annotationType, 0); + if (candidates.isEmpty()) { + var names = getConventionMethodNames(annotationType); + throw new MissingMethodException(targetType.getTypeName(), String.join("/", names)); + } + + var matches = new ArrayList>(); + + int parameterCount; + + if (criteria != null) { + parameterCount = criteria.getClass() == Object[].class ? criteria.length : 1; + } else { + parameterCount = 1; + } + + if (parameterCount > 0) { + for (var entry : candidates.entrySet()) { + var score = 0; + var methodParameters = entry.getKey().getParameters(); + if (methodParameters.length != parameterCount) { + continue; + } + + var index = 0; + assert criteria != null; + if (criteria.getClass() == Object[].class) { + for (var c : criteria) { + var currentScore = calculateParameterMatchScore(methodParameters[index], c); + if (currentScore == 0) { + break; + } + score += currentScore; + index++; + } + } else { + var currentScore = calculateParameterMatchScore(methodParameters[index], criteria); + if (currentScore > 0) { + score += currentScore; + index++; + } + } + if (index == parameterCount) { + matches.add(new AbstractMap.SimpleEntry<>(entry.getKey(), score + entry.getValue())); + } + } + } else { + for (var entry : candidates.entrySet()) { + var method = entry.getKey(); + if (method.getParameterCount() == 0) { + matches.add(new AbstractMap.SimpleEntry<>(method, entry.getValue())); + } + } + } + + if (matches.isEmpty()) { + for (var entry : candidates.entrySet()) { + var method = entry.getKey(); + var lastParameter = method.getParameters()[method.getParameterCount() - 1]; + if (lastParameter != null && lastParameter.isVarArgs()) { + matches.add(new AbstractMap.SimpleEntry<>(method, entry.getValue() + 1)); + } + } + } + + if (matches.isEmpty()) { + var names = getConventionMethodNames(annotationType); + var parameterNames = getParameterTypeName(criteria); + throw new MissingMethodException(targetType.getTypeName(), + String.join("/", names) + "(" + parameterNames + ")"); + } + + if (matches.size() > 1) { + + var maxScore = matches.stream().map(Map.Entry::getValue) + .max(Integer::compareTo) + .orElse(0); + var topMatches = matches.stream().filter(m -> Objects.equals(m.getValue(), maxScore)).toList(); + if (topMatches.size() > 1) { + throw new AmbiguousMethodException("Multiple methods were found for the specified operation"); + } + + return topMatches.get(0).getKey(); + } else { + return matches.get(0).getKey(); + } + } + + private static int calculateParameterMatchScore(Parameter parameter, Object criteria) { + if (criteria == null) { + + var parameterType = parameter.getType(); + + if (parameterType.isPrimitive()) { + return 0; + } + + if (parameterType == Object.class) { + return 2; + } + + if (parameterType == Object[].class) { + return 2; + } + + if (parameterType.isLocalClass()) { + return 1; + } + + if (parameterType.isArray()) { + return 1; + } + + if (parameterType.isInterface()) { + return 1; + } + + } else { + if (criteria.getClass() == parameter.getType()) { + return 3; + } + + if (criteria instanceof Long && parameter.getType() == long.class) { + return 3; + } + if (criteria instanceof Double && parameter.getType() == double.class) { + return 3; + } + if (criteria instanceof Float && parameter.getType() == float.class) { + return 3; + } + if (criteria instanceof Integer && parameter.getType() == int.class) { + return 3; + } + if (criteria instanceof Boolean && parameter.getType() == boolean.class) { + return 3; + } + + var parameterType = parameter.getType(); + + if (parameterType == Object.class) { + return 1; + } + + if (parameterType.isInstance(criteria)) { + return 2; + } + } + + return 0; + } + + private static Map getCandidateMethods(Class targetType, + Class annotationType, int level) { + var validNames = getConventionMethodNames(annotationType); + var result = new HashMap(); + + var methods = Arrays.stream(targetType.getDeclaredMethods()) + .filter(method -> method.isAnnotationPresent(annotationType) || validNames.contains(method.getName())) + .toList(); + + for (var method : methods) { + result.put(method, level); + } + + if (result.isEmpty() && targetType.getSuperclass() != Object.class + && !targetType.getSuperclass().isInterface()) { + result.putAll(getCandidateMethods(targetType.getSuperclass(), annotationType, level - 1)); + } + return result; + } + + private static String getParameterTypeName(Object[] criteria) { + if (criteria == null) { + return ""; + } + + var parameterTypeNames = new ArrayList(); + if (criteria.getClass() == Object[].class) { + for (var c : criteria) { + parameterTypeNames.add(c == null ? "null" : c.getClass().getTypeName()); + } + } else { + parameterTypeNames.add(criteria.getClass().getTypeName()); + } + return String.join(", ", parameterTypeNames); + } + + private static List getConventionMethodNames(Class annotationType) { + var names = new ArrayList(); + + var validNames = List.of(annotationType.getSimpleName(), annotationType.getSimpleName().replace("Factory", "")); + + for (var method : validNames) { + var firstChar = method.charAt(0); + var name = Character.toLowerCase(firstChar) + method.substring(1); + if (!names.contains(name)) { + names.add(name); + } + } + return names; + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfo.java b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java new file mode 100644 index 0000000..023c9c8 --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfo.java @@ -0,0 +1,83 @@ +package com.euonia.reflection; + +import com.euonia.osba.BusinessObject; + +import java.lang.reflect.Field; +import java.util.Objects; +import java.util.function.Supplier; + +/** + * Represents metadata about a property of a business object, including its name, type, default value, and display name. + * + * @param the type of the property value + */ +public class PropertyInfo implements Comparable> { + private final Class type; + private final String name; + private String friendlyName; + private Supplier defaultValue; + private Field field; + + public PropertyInfo(Class type, String name) { + this.name = name; + this.type = type; + } + + public PropertyInfo(Class type, String name, String friendlyName, Supplier defaultValue) { + this(type, name); + this.friendlyName = friendlyName; + this.defaultValue = defaultValue; + } + + public PropertyInfo(Class type, String name, String friendlyName, Class objectType, Supplier defaultValue) { + this(type, name, friendlyName, defaultValue); + try { + this.field = objectType.getDeclaredField(name); + } catch (NoSuchFieldException e) { + // + } + } + + public String getName() { + return name; + } + + public String getFriendlyName() { + if (!Objects.isNull(friendlyName)) { + return friendlyName; + } + + if (field != null) { + var annotation = field.getAnnotation(DisplayName.class); + if (annotation != null) { + return annotation.value(); + } + } + return name; + } + + public T getDefaultValue() { + return defaultValue == null ? null : defaultValue.get(); + } + + public Class getType() { + return type; + } + + public boolean isChild() { + return BusinessObject.class.isAssignableFrom(type); + } + + public Field getField() { + return field; + } + + public FieldData newFieldData(String name) { + return new FieldData(name); + } + + @Override + public int compareTo(PropertyInfo o) { + return this.name.compareTo(o.getName()); + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java new file mode 100644 index 0000000..42b6b6a --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoList.java @@ -0,0 +1,48 @@ +package com.euonia.reflection; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.Semaphore; + +public class PropertyInfoList extends ArrayList> { + private final Semaphore semaphore = new Semaphore(1); + + public PropertyInfoList() { + super(); + } + + public PropertyInfoList(List> list) { + super(list); + } + + private boolean locked = false; + + public boolean isLocked() { + return locked; + } + + public void lock() { + locked = true; + } + + public void unlock() { + locked = false; + } + + public PropertyInfo getOrAdd(PropertyInfo propertyInfo) { + try { + semaphore.acquire(); + var existing = this.stream().filter(p -> p.getName().equals(propertyInfo.getName())).findFirst().orElse(null); + if (existing != null) { + return existing; + } + this.add(propertyInfo); + return propertyInfo; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Thread was interrupted while trying to acquire semaphore.", e); + } finally { + semaphore.release(); + } + } +} diff --git a/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java new file mode 100644 index 0000000..45105bb --- /dev/null +++ b/osba/src/main/java/com/euonia/reflection/PropertyInfoManager.java @@ -0,0 +1,36 @@ +package com.euonia.reflection; + +import java.lang.reflect.Type; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class PropertyInfoManager { + private static final ConcurrentMap propertyCache = new ConcurrentHashMap<>(); + + public static PropertyInfoList getPropertyListCache(Type type) { + return propertyCache.computeIfAbsent(type, t -> { + PropertyInfoList propertyInfoList = new PropertyInfoList(); + FieldDataManager.initStaticFields((Class) t); + return propertyInfoList; + }); + } + + public static PropertyInfoList getRegisteredProperties(Type type) { + var list = getPropertyListCache(type); + return new PropertyInfoList(list); + } + + @SuppressWarnings("rawtypes") + public static PropertyInfo getRegisteredProperty(Type type, String propertyName) { + var properties = getRegisteredProperties(type); + return properties.stream() + .filter(property -> property.getName().equals(propertyName)) + .findFirst() + .orElse(null); + } + + public static PropertyInfo registerProperty(final Type type, PropertyInfo propertyInfo) { + var list = getPropertyListCache(type); + return list.getOrAdd(propertyInfo); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..0bc1bad --- /dev/null +++ b/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + com.euonia + parent + ${revision} + + pom + + core + osba + domain + + + euonia-parent + Euonia framework for Java. + https://euonia.com + + + https://github.com/NerosoftDev/euonia-java + scm:git:git://github.com/NerosoftDev/euonia-java.git + + + + + damon + Codespilot + zhaorong@outlook.com + https://github.com/Codespilot + + + + + 1.0.0 + 17 + 1.0 + UTF-8 + UTF-8 + 17 + 17 + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.15.0 + + true + ${maven.compiler.release} + + + + + diff --git a/sample/.gitattributes b/sample/.gitattributes new file mode 100644 index 0000000..3b41682 --- /dev/null +++ b/sample/.gitattributes @@ -0,0 +1,2 @@ +/mvnw text eol=lf +*.cmd text eol=crlf diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 0000000..667aaef --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1,33 @@ +HELP.md +target/ +.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ diff --git a/sample/.mvn/wrapper/maven-wrapper.properties b/sample/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..216df05 --- /dev/null +++ b/sample/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip diff --git a/sample/mvnw b/sample/mvnw new file mode 100755 index 0000000..bd8896b --- /dev/null +++ b/sample/mvnw @@ -0,0 +1,295 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Apache Maven Wrapper startup batch script, version 3.3.4 +# +# Optional ENV vars +# ----------------- +# JAVA_HOME - location of a JDK home dir, required when download maven via java source +# MVNW_REPOURL - repo url base for downloading maven distribution +# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output +# ---------------------------------------------------------------------------- + +set -euf +[ "${MVNW_VERBOSE-}" != debug ] || set -x + +# OS specific support. +native_path() { printf %s\\n "$1"; } +case "$(uname)" in +CYGWIN* | MINGW*) + [ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")" + native_path() { cygpath --path --windows "$1"; } + ;; +esac + +# set JAVACMD and JAVACCMD +set_java_home() { + # For Cygwin and MinGW, ensure paths are in Unix format before anything is touched + if [ -n "${JAVA_HOME-}" ]; then + if [ -x "$JAVA_HOME/jre/sh/java" ]; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + JAVACCMD="$JAVA_HOME/jre/sh/javac" + else + JAVACMD="$JAVA_HOME/bin/java" + JAVACCMD="$JAVA_HOME/bin/javac" + + if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then + echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2 + echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2 + return 1 + fi + fi + else + JAVACMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v java + )" || : + JAVACCMD="$( + 'set' +e + 'unset' -f command 2>/dev/null + 'command' -v javac + )" || : + + if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then + echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2 + return 1 + fi + fi +} + +# hash string like Java String::hashCode +hash_string() { + str="${1:-}" h=0 + while [ -n "$str" ]; do + char="${str%"${str#?}"}" + h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296)) + str="${str#?}" + done + printf %x\\n $h +} + +verbose() { :; } +[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; } + +die() { + printf %s\\n "$1" >&2 + exit 1 +} + +trim() { + # MWRAPPER-139: + # Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds. + # Needed for removing poorly interpreted newline sequences when running in more + # exotic environments such as mingw bash on Windows. + printf "%s" "${1}" | tr -d '[:space:]' +} + +scriptDir="$(dirname "$0")" +scriptName="$(basename "$0")" + +# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties +while IFS="=" read -r key value; do + case "${key-}" in + distributionUrl) distributionUrl=$(trim "${value-}") ;; + distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;; + esac +done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties" +[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" + +case "${distributionUrl##*/}" in +maven-mvnd-*bin.*) + MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ + case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in + *AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;; + :Darwin*x86_64) distributionPlatform=darwin-amd64 ;; + :Darwin*arm64) distributionPlatform=darwin-aarch64 ;; + :Linux*x86_64*) distributionPlatform=linux-amd64 ;; + *) + echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2 + distributionPlatform=linux-amd64 + ;; + esac + distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip" + ;; +maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;; +*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;; +esac + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}" +distributionUrlName="${distributionUrl##*/}" +distributionUrlNameMain="${distributionUrlName%.*}" +distributionUrlNameMain="${distributionUrlNameMain%-bin}" +MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}" +MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")" + +exec_maven() { + unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || : + exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD" +} + +if [ -d "$MAVEN_HOME" ]; then + verbose "found existing MAVEN_HOME at $MAVEN_HOME" + exec_maven "$@" +fi + +case "${distributionUrl-}" in +*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;; +*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;; +esac + +# prepare tmp dir +if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then + clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; } + trap clean HUP INT TERM EXIT +else + die "cannot create temp dir" +fi + +mkdir -p -- "${MAVEN_HOME%/*}" + +# Download and Install Apache Maven +verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +verbose "Downloading from: $distributionUrl" +verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +# select .zip or .tar.gz +if ! command -v unzip >/dev/null; then + distributionUrl="${distributionUrl%.zip}.tar.gz" + distributionUrlName="${distributionUrl##*/}" +fi + +# verbose opt +__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR='' +[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v + +# normalize http auth +case "${MVNW_PASSWORD:+has-password}" in +'') MVNW_USERNAME='' MVNW_PASSWORD='' ;; +has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;; +esac + +if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then + verbose "Found wget ... using wget" + wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl" +elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then + verbose "Found curl ... using curl" + curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl" +elif set_java_home; then + verbose "Falling back to use Java to download" + javaSource="$TMP_DOWNLOAD_DIR/Downloader.java" + targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName" + cat >"$javaSource" <<-END + public class Downloader extends java.net.Authenticator + { + protected java.net.PasswordAuthentication getPasswordAuthentication() + { + return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() ); + } + public static void main( String[] args ) throws Exception + { + setDefault( new Downloader() ); + java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() ); + } + } + END + # For Cygwin/MinGW, switch paths to Windows format before running javac and java + verbose " - Compiling Downloader.java ..." + "$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java" + verbose " - Running Downloader.java ..." + "$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")" +fi + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +if [ -n "${distributionSha256Sum-}" ]; then + distributionSha256Result=false + if [ "$MVN_CMD" = mvnd.sh ]; then + echo "Checksum validation is not supported for maven-mvnd." >&2 + echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + elif command -v sha256sum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then + distributionSha256Result=true + fi + elif command -v shasum >/dev/null; then + if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then + distributionSha256Result=true + fi + else + echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2 + echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2 + exit 1 + fi + if [ $distributionSha256Result = false ]; then + echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2 + echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2 + exit 1 + fi +fi + +# unzip and move +if command -v unzip >/dev/null; then + unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip" +else + tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar" +fi + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +actualDistributionDir="" + +# First try the expected directory name (for regular distributions) +if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then + if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then + actualDistributionDir="$distributionUrlNameMain" + fi +fi + +# If not found, search for any directory with the Maven executable (for snapshots) +if [ -z "$actualDistributionDir" ]; then + # enable globbing to iterate over items + set +f + for dir in "$TMP_DOWNLOAD_DIR"/*; do + if [ -d "$dir" ]; then + if [ -f "$dir/bin/$MVN_CMD" ]; then + actualDistributionDir="$(basename "$dir")" + break + fi + fi + done + set -f +fi + +if [ -z "$actualDistributionDir" ]; then + verbose "Contents of $TMP_DOWNLOAD_DIR:" + verbose "$(ls -la "$TMP_DOWNLOAD_DIR")" + die "Could not find Maven distribution directory in extracted archive" +fi + +verbose "Found extracted Maven distribution directory: $actualDistributionDir" +printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url" +mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME" + +clean || : +exec_maven "$@" diff --git a/sample/mvnw.cmd b/sample/mvnw.cmd new file mode 100644 index 0000000..92450f9 --- /dev/null +++ b/sample/mvnw.cmd @@ -0,0 +1,189 @@ +<# : batch portion +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Apache Maven Wrapper startup batch script, version 3.3.4 +@REM +@REM Optional ENV vars +@REM MVNW_REPOURL - repo url base for downloading maven distribution +@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven +@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output +@REM ---------------------------------------------------------------------------- + +@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0) +@SET __MVNW_CMD__= +@SET __MVNW_ERROR__= +@SET __MVNW_PSMODULEP_SAVE=%PSModulePath% +@SET PSModulePath= +@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @( + IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B) +) +@SET PSModulePath=%__MVNW_PSMODULEP_SAVE% +@SET __MVNW_PSMODULEP_SAVE= +@SET __MVNW_ARG0_NAME__= +@SET MVNW_USERNAME= +@SET MVNW_PASSWORD= +@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*) +@echo Cannot start maven from wrapper >&2 && exit /b 1 +@GOTO :EOF +: end batch / begin powershell #> + +$ErrorActionPreference = "Stop" +if ($env:MVNW_VERBOSE -eq "true") { + $VerbosePreference = "Continue" +} + +# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties +$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl +if (!$distributionUrl) { + Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties" +} + +switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) { + "maven-mvnd-*" { + $USE_MVND = $true + $distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip" + $MVN_CMD = "mvnd.cmd" + break + } + default { + $USE_MVND = $false + $MVN_CMD = $script -replace '^mvnw','mvn' + break + } +} + +# apply MVNW_REPOURL and calculate MAVEN_HOME +# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-,maven-mvnd--}/ +if ($env:MVNW_REPOURL) { + $MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" } + $distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')" +} +$distributionUrlName = $distributionUrl -replace '^.*/','' +$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$','' + +$MAVEN_M2_PATH = "$HOME/.m2" +if ($env:MAVEN_USER_HOME) { + $MAVEN_M2_PATH = "$env:MAVEN_USER_HOME" +} + +if (-not (Test-Path -Path $MAVEN_M2_PATH)) { + New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null +} + +$MAVEN_WRAPPER_DISTS = $null +if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) { + $MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists" +} else { + $MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists" +} + +$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain" +$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join '' +$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME" + +if (Test-Path -Path "$MAVEN_HOME" -PathType Container) { + Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME" + Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" + exit $? +} + +if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) { + Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl" +} + +# prepare tmp dir +$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile +$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir" +$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null +trap { + if ($TMP_DOWNLOAD_DIR.Exists) { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } + } +} + +New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null + +# Download and Install Apache Maven +Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..." +Write-Verbose "Downloading from: $distributionUrl" +Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName" + +$webclient = New-Object System.Net.WebClient +if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) { + $webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD) +} +[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 +$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null + +# If specified, validate the SHA-256 sum of the Maven distribution zip file +$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum +if ($distributionSha256Sum) { + if ($USE_MVND) { + Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." + } + Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash + if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) { + Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property." + } +} + +# unzip and move +Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null + +# Find the actual extracted directory name (handles snapshots where filename != directory name) +$actualDistributionDir = "" + +# First try the expected directory name (for regular distributions) +$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain" +$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD" +if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) { + $actualDistributionDir = $distributionUrlNameMain +} + +# If not found, search for any directory with the Maven executable (for snapshots) +if (!$actualDistributionDir) { + Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object { + $testPath = Join-Path $_.FullName "bin/$MVN_CMD" + if (Test-Path -Path $testPath -PathType Leaf) { + $actualDistributionDir = $_.Name + } + } +} + +if (!$actualDistributionDir) { + Write-Error "Could not find Maven distribution directory in extracted archive" +} + +Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir" +Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null +try { + Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null +} catch { + if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) { + Write-Error "fail to move MAVEN_HOME" + } +} finally { + try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null } + catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" } +} + +Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD" diff --git a/sample/pom.xml b/sample/pom.xml new file mode 100644 index 0000000..7d9701b --- /dev/null +++ b/sample/pom.xml @@ -0,0 +1,127 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + 4.0.6 + + + com.euonia + sample + 0.0.1-SNAPSHOT + sample + sample + + + + + + + + + + + + + + + 25 + + + + com.euonia + osba + 1.0.0 + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-webmvc + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 3.0.2 + + + + com.mysql + mysql-connector-j + runtime + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-data-jpa-test + test + + + org.springframework.boot + spring-boot-starter-webmvc-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + default-compile + compile + + compile + + + + + org.projectlombok + lombok + + + + + + default-testCompile + test-compile + + testCompile + + + + + org.projectlombok + lombok + + + + + + + + + + diff --git a/sample/src/main/java/com/euonia/sample/SampleApplication.java b/sample/src/main/java/com/euonia/sample/SampleApplication.java new file mode 100644 index 0000000..6f3ee2c --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/SampleApplication.java @@ -0,0 +1,13 @@ +package com.euonia.sample; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class SampleApplication { + + public static void main(String[] args) { + SpringApplication.run(SampleApplication.class, args); + } + +} diff --git a/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java b/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java new file mode 100644 index 0000000..00e74f2 --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/domain/aggregate/User.java @@ -0,0 +1,16 @@ +package com.euonia.sample.domain.aggregate; + +import com.euonia.osba.EditableObject; + +public class User extends EditableObject { + + private String name; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/sample/src/main/java/com/euonia/sample/domain/package-info.java b/sample/src/main/java/com/euonia/sample/domain/package-info.java new file mode 100644 index 0000000..1c469f8 --- /dev/null +++ b/sample/src/main/java/com/euonia/sample/domain/package-info.java @@ -0,0 +1 @@ +package com.euonia.sample.domain; diff --git a/sample/src/main/resources/application.yaml b/sample/src/main/resources/application.yaml new file mode 100644 index 0000000..6061b5a --- /dev/null +++ b/sample/src/main/resources/application.yaml @@ -0,0 +1,3 @@ +spring: + application: + name: sample diff --git a/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java b/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java new file mode 100644 index 0000000..7518c97 --- /dev/null +++ b/sample/src/test/java/com/euonia/sample/SampleApplicationTests.java @@ -0,0 +1,13 @@ +package com.euonia.sample; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class SampleApplicationTests { + + @Test + void contextLoads() { + } + +}