Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,10 @@ package it exports, `LICENSE`, `CHANGELOG.md` with the fragment rule in
`changelog.d/README.md`, and the two workflows: CI
(`.github/workflows/ci.yml`) and the release workflow
(`.github/workflows/release.yml`). The package holds time-based and
deterministic generation (`Uxid.generate` and `UxidOptions`). Decoding, the
validity checks, the monotonic generator and the vendored corpus arrive
with later changes; until they do, the sentences in this file about the
deterministic generation (`Uxid.generate` and `UxidOptions`), decoding
(`Uxid.decode`, `DecodeOptions` and `DecodeResult`) and the validity checks
(`Uxid.isValid` and `Uxid.isDeterministic`). The monotonic generator and the
vendored corpus arrive with later changes; until they do, the sentences in this file about the
1.0.0 package and its corpus describe the target, not the current tree.

- **The package is `systems.riddler:uxid`.** Version 1.0.0 is its first
Expand Down
6 changes: 6 additions & 0 deletions changelog.d/uxjv-nyt.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
### Added

- `Uxid.decode(String)` and `Uxid.decode(String, DecodeOptions)` read an id back to a `DecodeResult`: a `DecodeResult.Decoded` with the prefix, the size, whether the time is compact, whether the body is deterministic and the time in milliseconds, or a `DecodeResult.Invalid` whose `InvalidReason` is `empty`, `invalid_length` or `invalid_character`, the first rule the body breaks in that order. Malformed input is answered, never thrown.
- `DecodeOptions` takes the delimiter and a `java.time.Clock` that supplies a compact time's high bits; a compact time more than one day ahead of the clock is read from the 2^40 ms window before, which with a clock in the first window after the Unix epoch is a negative time.
- The body lengths 10 and 18 always decode as standard time, so a compact `xs` or compact `m` id decodes to its size, not compact, with a time that is not the one it was generated at.
- `Uxid.isValid(String)` and `Uxid.isValid(String, String delimiter, String requiredPrefix)` answer whether a string is a well-formed id and, given a prefix, carries exactly that prefix; `Uxid.isDeterministic(String)` and `Uxid.isDeterministic(String, String delimiter)` answer whether it is a well-formed deterministic id. Neither reads a clock.
18 changes: 18 additions & 0 deletions src/main/java/systems/riddler/uxid/Base32.java
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,24 @@ private static String alphabet(LetterCase letterCase) {
return letterCase == LetterCase.UPPER ? UPPER : LOWER;
}

/**
* The 5-bit value of a symbol in either case, or -1 for any other character: the letters {@code
* I}, {@code L}, {@code O} and {@code U} are not symbols and are not read as {@code 1} or {@code
* 0}.
*/
static int value(char c) {
if (c >= 'A' && c <= 'Z') {
c = (char) (c + ('a' - 'A'));
}
if (c >= '0' && c <= '9') {
return c - '0';
}
if (c >= 'a' && c <= 'z') {
return LOWER.indexOf(c);
}
return -1;
}

/**
* Appends {@code chars} characters holding {@code value}, whose width is at most {@code chars x
* 5} bits; the first group is the short one when the width is not a multiple of 5.
Expand Down
109 changes: 109 additions & 0 deletions src/main/java/systems/riddler/uxid/DecodeOptions.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package systems.riddler.uxid;

import java.time.Clock;
import java.util.Objects;

/**
* The options of one {@link Uxid#decode(String, DecodeOptions) decode} call beside the string,
* built with {@link #builder()}. An instance is immutable and safe to share between threads and
* calls.
*
* <p>The defaults are what {@link Uxid#decode(String)} uses: the delimiter {@code _} and the system
* clock in UTC, {@link Clock#systemUTC()}.
*
* <pre>{@code
* DecodeOptions options =
* DecodeOptions.builder()
* .delimiter("--")
* .clock(Clock.fixed(Instant.ofEpochMilli(1_700_000_000_000L), ZoneOffset.UTC))
* .build();
* DecodeResult result = Uxid.decode("usr--hf7yat0004hm5", options);
* }</pre>
*/
public final class DecodeOptions {
/** Every default: the delimiter {@code _} and the system clock in UTC. */
static final DecodeOptions DEFAULTS = builder().build();

private final String delimiter;
private final Clock clock;

private DecodeOptions(Builder builder) {
this.delimiter = builder.delimiter;
this.clock = builder.clock;
}

/**
* Returns a builder holding every default.
*
* @return a new builder
*/
public static Builder builder() {
return new Builder();
}

String delimiter() {
return delimiter;
}

Clock clock() {
return clock;
}

/** Refuses a null or empty delimiter, the one check every delimiter-taking call shares. */
static String requireDelimiter(String delimiter) {
Objects.requireNonNull(delimiter, "delimiter");
if (delimiter.isEmpty()) {
throw new IllegalArgumentException("the delimiter must not be empty");
}
return delimiter;
}

/**
* Builds a {@link DecodeOptions}. Each setter refuses a value that is wrong on its own. A builder
* is not safe to share between threads.
*/
public static final class Builder {
private String delimiter = "_";
private Clock clock = Clock.systemUTC();

private Builder() {}

/**
* Sets the delimiter between the prefix and the body; the default is {@code _}. It must be the
* one the id was generated with, since an id does not record it. A delimiter made of Crockford
* Base32 characters makes the split ambiguous and is not supported.
*
* @param delimiter the delimiter, not empty
* @return this builder
* @throws IllegalArgumentException when {@code delimiter} is empty
* @throws NullPointerException when {@code delimiter} is null
*/
public Builder delimiter(String delimiter) {
this.delimiter = requireDelimiter(delimiter);
return this;
}

/**
* Sets the decoder's clock, which supplies a compact time's high bits; the default is {@link
* Clock#systemUTC()}. Only its {@link Clock#millis() millis} are read, once per call, and only
* for a compact body. A fixed clock makes the decoding of compact ids reproducible.
*
* @param clock the clock
* @return this builder
* @throws NullPointerException when {@code clock} is null
*/
public Builder clock(Clock clock) {
this.clock = Objects.requireNonNull(clock, "clock");
return this;
}

/**
* Builds the options.
*
* @return the options
*/
public DecodeOptions build() {
return new DecodeOptions(this);
}
}
}
75 changes: 75 additions & 0 deletions src/main/java/systems/riddler/uxid/DecodeResult.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package systems.riddler.uxid;

import java.util.Objects;
import java.util.Optional;
import java.util.OptionalLong;

/**
* What {@link Uxid#decode(String, DecodeOptions) decode} answers for a string: a {@link Decoded}
* for a well-formed id, or an {@link Invalid} naming the rule the string broke. Decoding never
* throws on malformed input; the caller inspects the result.
*
* <pre>{@code
* DecodeResult result = Uxid.decode("cus_01hf7yat0004h1");
* if (result instanceof DecodeResult.Decoded decoded) {
* decoded.prefix(); // Optional[cus]
* decoded.size(); // Size.S
* decoded.time(); // OptionalLong[1700000000000]
* } else if (result instanceof DecodeResult.Invalid invalid) {
* invalid.reason().wireName(); // such as "invalid_length"
* }
* }</pre>
*/
public sealed interface DecodeResult permits DecodeResult.Decoded, DecodeResult.Invalid {

/**
* What a well-formed id says about itself: its prefix, the size and time form its body length
* gives, whether its body is deterministic, and the time it carries. The random field, and a
* deterministic body's hash, are not read back.
*
* @param prefix everything before the delimiter the body follows, or empty when the id has no
* delimiter. An id that starts with the delimiter, which no producer emits, has the prefix
* {@code ""}, best read as no prefix
* @param size the size the body length gives
* @param compact whether the body length is a compact-time one (13, 21 or 24). The lengths 10 and
* 18 are always read as standard time, so a compact {@code xs} or compact {@code m} id
* decodes with this {@code false}
* @param deterministic whether the body starts with the deterministic marker {@code z} or {@code
* Z}
* @param time the time the body carries, in milliseconds since the Unix epoch, or empty for a
* deterministic body, which carries none. A compact time's high bits come from the decoder's
* clock, so it can be before the epoch
*/
record Decoded(
Optional<String> prefix, Size size, boolean compact, boolean deterministic, OptionalLong time)
implements DecodeResult {

/**
* Creates a decoded value.
*
* @throws NullPointerException when {@code prefix}, {@code size} or {@code time} is null
*/
public Decoded {
Objects.requireNonNull(prefix, "prefix");
Objects.requireNonNull(size, "size");
Objects.requireNonNull(time, "time");
}
}

/**
* A refused string, with the first rule it broke.
*
* @param reason the rule the body broke
*/
record Invalid(InvalidReason reason) implements DecodeResult {

/**
* Creates a refusal.
*
* @throws NullPointerException when {@code reason} is null
*/
public Invalid {
Objects.requireNonNull(reason, "reason");
}
}
}
134 changes: 134 additions & 0 deletions src/main/java/systems/riddler/uxid/Decoder.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package systems.riddler.uxid;

import java.util.Optional;
import java.util.OptionalLong;
import java.util.function.LongSupplier;

/** Reads a string to a {@link DecodeResult} under the wire format's decoding rules. */
final class Decoder {
/** The span of a compact time field, 2^40 milliseconds. */
static final long COMPACT_WINDOW = 1L << 40;

/**
* How far ahead of the decoder's clock a compact time may be before it is read as one from the
* window before: one day in milliseconds.
*/
static final long ONE_DAY = 86_400_000L;

private Decoder() {}

/**
* Decodes {@code id} under {@code delimiter}, a non-empty string, calling {@code clock} only for
* a compact body.
*/
static DecodeResult decode(String id, String delimiter, LongSupplier clock) {
int at = lastDelimiter(id, delimiter);
Optional<String> prefix = at < 0 ? Optional.empty() : Optional.of(id.substring(0, at));
String body = at < 0 ? id : id.substring(at + delimiter.length());

if (body.isEmpty()) {
return new DecodeResult.Invalid(InvalidReason.EMPTY);
}
int length = utf8Length(body);
Size size = sizeOf(length);
if (size == null) {
return new DecodeResult.Invalid(InvalidReason.INVALID_LENGTH);
}
for (int i = 0; i < body.length(); i++) {
if (Base32.value(body.charAt(i)) < 0) {
return new DecodeResult.Invalid(InvalidReason.INVALID_CHARACTER);
}
}

boolean compact = length == 13 || length == 21 || length == 24;
char first = body.charAt(0);
boolean deterministic = first == 'z' || first == 'Z';
OptionalLong time;
if (deterministic) {
time = OptionalLong.empty();
} else if (compact) {
time = OptionalLong.of(compactTime(read(body, 0, 8), clock.getAsLong()));
} else {
time = OptionalLong.of((read(body, 0, 1) & 7) << 45 | read(body, 1, 10));
}
return new DecodeResult.Decoded(prefix, size, compact, deterministic, time);
}

/**
* The index of the last delimiter a left-to-right scan finds, its matches not overlapping, or -1
* when there is none. Under {@code --}, {@code a---b} splits after {@code a}, as the reference's
* split does, not at the last {@code --}.
*/
private static int lastDelimiter(String id, String delimiter) {
int at = -1;
int from = 0;
while (true) {
int found = id.indexOf(delimiter, from);
if (found < 0) {
return at;
}
at = found;
from = found + delimiter.length();
}
}

/**
* The length of {@code text} in UTF-8 bytes. An unpaired surrogate counts as three bytes, the
* width of the replacement character U+FFFD.
*/
private static int utf8Length(String text) {
int bytes = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c < 0x80) {
bytes += 1;
} else if (c < 0x800) {
bytes += 2;
} else if (Character.isHighSurrogate(c)
&& i + 1 < text.length()
&& Character.isLowSurrogate(text.charAt(i + 1))) {
bytes += 4;
i++;
} else {
bytes += 3;
}
}
return bytes;
}

/** The size a body length gives, or null for a length a producer never emits. */
private static Size sizeOf(int length) {
return switch (length) {
case 10 -> Size.XS;
case 13, 14 -> Size.S;
case 18 -> Size.M;
case 21, 22 -> Size.L;
case 24, 26 -> Size.XL;
default -> null;
};
}

/** Reads the symbols from {@code start} to {@code end} as one integer, 5 bits each. */
private static long read(String body, int start, int end) {
long value = 0;
for (int i = start; i < end; i++) {
value = value << 5 | Base32.value(body.charAt(i));
}
return value;
}

/**
* The time of the compact field {@code low} under the clock {@code now}: the clock's bits above
* bit 40 with {@code low}, less one window when that is more than one day ahead of the clock. The
* window is the clock's rounded down, so with the clock in the first window after the epoch (or
* before the epoch) the window before ends at or before the epoch and the time can be negative;
* nothing clamps it at zero.
*/
static long compactTime(long low, long now) {
long time = Math.floorDiv(now, COMPACT_WINDOW) * COMPACT_WINDOW + low;
if (time - now > ONE_DAY) {
time -= COMPACT_WINDOW;
}
return time;
}
}
Loading
Loading