Skip to content
Open
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: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]

### Added
- Configurable UUID⇄FriendlyId encoding (`FriendlyIdEncoding`): `STANDARD` (default, the bit-shifting
pairing used since 1.1.0) and `LEGACY` (Szudzik's elegant pairing from the 1.0.x line). Set globally
with `FriendlyIds.setEncoding(...)` or, with the Spring Boot starter, via the
`com.devskiller.friendly-id.encoding=legacy` property. The two encodings are wire-incompatible —
decoding an identifier with the wrong one silently yields a different UUID — so services must keep
the encoding their identifiers were issued with (pinned by test vectors generated from released
1.0.4 and 1.1.0 artifacts).
- FriendlyId value object type (`com.devskiller.friendly_id.type.FriendlyId`) as an alternative to raw UUID
- JPA integration module (`friendly-id-jpa`) with automatic AttributeConverter
- OpenFeign integration module (`friendly-id-openfeign`) for FriendlyId support in Feign clients
Expand Down
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,32 @@ UUID and `FriendlyId` parameters are automatically converted to FriendlyId strin

Version 2.0 introduces several breaking changes to support Spring Boot 4 and Jackson 3.

#### Encoding compatibility (1.0.x vs 1.1.0+)

Version 1.1.0 changed the internal UUID pairing algorithm, so **1.0.x and 1.1.0+ produce
different FriendlyId strings for the same UUID** — and decoding an identifier with the wrong
algorithm silently yields a different UUID. Since 2.0 the algorithm is selectable:

| Encoding | Wire-compatible with | Notes |
|------------|----------------------|-------|
| `STANDARD` | 1.1.0 and newer | default |
| `LEGACY` | 1.0.x | Szudzik's elegant pairing |

Services upgrading **from 1.0.x** must opt into the legacy encoding to keep their published
identifiers stable — either programmatically at startup:

```java
FriendlyIds.setEncoding(FriendlyIdEncoding.LEGACY);
```

or, with the Spring Boot starter, via a property:

```properties
com.devskiller.friendly-id.encoding=legacy
```

Services upgrading from 1.1.0+ need no changes — `STANDARD` is the default.

#### Requirements

| Version | Java | Spring Boot | Jackson |
Expand Down
10 changes: 10 additions & 0 deletions friendly-id-spring-boot-starter/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,15 @@
<artifactId>spring-boot-autoconfigure-processor</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,19 @@
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

import com.devskiller.friendly_id.FriendlyIds;
import com.devskiller.friendly_id.spring.EnableFriendlyId;

/**
* Auto-configuration for FriendlyId integration with Spring Boot.
* <p>
* Automatically enables FriendlyId converters and Jackson module when Spring Boot is detected.
* Can be disabled by setting {@code com.devskiller.friendly-id.enabled=false} in application properties.
* <p>
* The encoding can be selected with {@code com.devskiller.friendly-id.encoding} — set it to
* {@code legacy} to stay wire-compatible with identifiers issued by the friendly-id 1.0.x line.
*/
@AutoConfiguration
@ConditionalOnWebApplication
Expand All @@ -20,7 +25,12 @@
havingValue = "true",
matchIfMissing = true
)
@EnableConfigurationProperties(FriendlyIdProperties.class)
@EnableFriendlyId
public class FriendlyIdAutoConfiguration {

FriendlyIdAutoConfiguration(FriendlyIdProperties properties) {
FriendlyIds.setEncoding(properties.getEncoding());
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.devskiller.friendly_id.boot;

import org.springframework.boot.context.properties.ConfigurationProperties;

import com.devskiller.friendly_id.FriendlyIdEncoding;

/**
* Configuration properties for the FriendlyId Spring Boot integration.
*/
@ConfigurationProperties(prefix = "com.devskiller.friendly-id")
public class FriendlyIdProperties {

/**
* Whether to enable the FriendlyId auto-configuration.
*/
private boolean enabled = true;

/**
* Encoding used for UUID to FriendlyId conversion. Use LEGACY to stay
* wire-compatible with identifiers issued by the friendly-id 1.0.x line.
*/
private FriendlyIdEncoding encoding = FriendlyIdEncoding.STANDARD;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public FriendlyIdEncoding getEncoding() {
return encoding;
}

public void setEncoding(FriendlyIdEncoding encoding) {
this.encoding = encoding;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.devskiller.friendly_id.boot;

import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;

import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;

import com.devskiller.friendly_id.FriendlyIdEncoding;
import com.devskiller.friendly_id.FriendlyIds;

import static org.assertj.core.api.Assertions.assertThat;

class FriendlyIdAutoConfigurationTest {

private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
.withConfiguration(AutoConfigurations.of(FriendlyIdAutoConfiguration.class));

@AfterEach
void restoreDefaultEncoding() {
FriendlyIds.setEncoding(FriendlyIdEncoding.STANDARD);
}

@Test
void usesStandardEncodingByDefault() {
contextRunner.run(context -> {
assertThat(context).hasSingleBean(FriendlyIdAutoConfiguration.class);
assertThat(FriendlyIds.getEncoding()).isEqualTo(FriendlyIdEncoding.STANDARD);
});
}

@Test
void encodingPropertySwitchesToLegacy() {
contextRunner
.withPropertyValues("com.devskiller.friendly-id.encoding=legacy")
.run(context -> {
assertThat(context).hasSingleBean(FriendlyIdAutoConfiguration.class);
assertThat(FriendlyIds.getEncoding()).isEqualTo(FriendlyIdEncoding.LEGACY);
});
}

@Test
void canBeDisabled() {
contextRunner
.withPropertyValues("com.devskiller.friendly-id.enabled=false")
.run(context -> assertThat(context).doesNotHaveBean(FriendlyIdAutoConfiguration.class));
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package com.devskiller.friendly_id;

import java.math.BigInteger;

import static java.math.BigInteger.ONE;
import static java.math.BigInteger.TWO;

/**
* https://stackoverflow.com/questions/919612/mapping-two-integers-to-one-in-a-unique-and-deterministic-way/13871379#13871379
*/
class ElegantPairing {

private ElegantPairing() {
}

static BigInteger pair(BigInteger first, BigInteger second) {
BigInteger a = first.signum() >= 0 ? TWO.multiply(first) : TWO.negate().multiply(first).subtract(ONE);
BigInteger b = second.signum() >= 0 ? TWO.multiply(second) : TWO.negate().multiply(second).subtract(ONE);
if (a.compareTo(b) >= 0) {
return a.multiply(a).add(a).add(b);
} else {
return b.multiply(b).add(a);
}
}

static BigInteger[] unpair(BigInteger value) {
BigInteger a = sqrt(value);
BigInteger b = value.subtract(a.multiply(a));
return a.compareTo(b) > 0 ?
new BigInteger[]{recoverSignedValue(b), recoverSignedValue(a)} :
new BigInteger[]{recoverSignedValue(a), recoverSignedValue(b.subtract(a))};
}

private static BigInteger recoverSignedValue(BigInteger value) {
return value.testBit(0) ? value.divide(TWO).negate().subtract(ONE) : value.divide(TWO);
}

/**
* Source: https://stackoverflow.com/a/36187890/516167
*/
private static BigInteger sqrt(BigInteger n) {
BigInteger a = BigInteger.ONE;
BigInteger b = n.shiftRight(1).add(TWO); // (n >> 1) + 2 (ensure 0 doesn't show up)
while (b.compareTo(a) >= 0) {
BigInteger mid = a.add(b).shiftRight(1); // (a+b) >> 1
if (mid.multiply(mid).compareTo(n) > 0)
b = mid.subtract(BigInteger.ONE);
else
a = mid.add(BigInteger.ONE);
}
return a.subtract(BigInteger.ONE);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package com.devskiller.friendly_id;

import java.math.BigInteger;

/**
* Strategy used to map the two 64-bit halves of a UUID onto the single
* {@link BigInteger} that is Base62-encoded into a FriendlyId string.
* <p>
* The two strategies produce <strong>incompatible</strong> FriendlyId strings for
* the same UUID. Decoding a FriendlyId with the wrong strategy does not fail —
* it silently yields a different UUID — so a service must keep using the strategy
* its identifiers were originally issued with.
*
* <ul>
* <li>{@link #STANDARD} — bit-shifting pairing, the default since 1.1.0</li>
* <li>{@link #LEGACY} — Szudzik's elegant pairing, used by the 1.0.x line</li>
* </ul>
*
* @since 2.0.0-beta6
* @see FriendlyIds#setEncoding(FriendlyIdEncoding)
*/
public enum FriendlyIdEncoding {

/**
* Bit-shifting pairing ({@code hi * 2^64 + unsigned(lo)}), the default encoding
* since friendly-id 1.1.0.
*/
STANDARD {
@Override
BigInteger pair(BigInteger hi, BigInteger lo) {
return BigIntegerPairing.pair(hi, lo);
}

@Override
BigInteger[] unpair(BigInteger value) {
return BigIntegerPairing.unpair(value);
}
},

/**
* Szudzik's elegant pairing, the encoding used by the friendly-id 1.0.x line.
* Use this to stay wire-compatible with identifiers issued by 1.0.x.
*/
LEGACY {
@Override
BigInteger pair(BigInteger hi, BigInteger lo) {
return ElegantPairing.pair(hi, lo);
}

@Override
BigInteger[] unpair(BigInteger value) {
return ElegantPairing.unpair(value);
}
};

abstract BigInteger pair(BigInteger hi, BigInteger lo);

abstract BigInteger[] unpair(BigInteger value);

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,42 @@
*/
public final class FriendlyIds {

private static volatile FriendlyIdEncoding encoding = FriendlyIdEncoding.STANDARD;

private FriendlyIds() {
// utility class
}

/**
* Sets the global {@link FriendlyIdEncoding} used by all conversions in this library
* (including the Jackson, JPA, jOOQ, OpenFeign and Spring integrations).
* <p>
* Intended to be called once during application startup, before any conversion happens.
* Identifiers encoded with one strategy silently decode to a <em>different</em> UUID
* under the other, so switching at runtime on live traffic is not supported.
* <p>
* With the Spring Boot starter this can be set declaratively via the
* {@code com.devskiller.friendly-id.encoding} property.
*
* @param friendlyIdEncoding encoding to use, must not be null
* @throws NullPointerException if friendlyIdEncoding is null
* @since 2.0.0-beta6
*/
public static void setEncoding(FriendlyIdEncoding friendlyIdEncoding) {
Objects.requireNonNull(friendlyIdEncoding, "Encoding cannot be null");
encoding = friendlyIdEncoding;
}

/**
* Returns the global {@link FriendlyIdEncoding}, {@link FriendlyIdEncoding#STANDARD} by default.
*
* @return the encoding used by all conversions in this library
* @since 2.0.0-beta6
*/
public static FriendlyIdEncoding getEncoding() {
return encoding;
}

/**
* Creates a random FriendlyId string.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ class Url62 {
* @return url62 encoded UUID
*/
static String encode(UUID uuid) {
BigInteger pair = UuidConverter.toBigInteger(uuid);
BigInteger pair = UuidConverter.toBigInteger(uuid, FriendlyIds.getEncoding());
return Base62.encode(pair);
}

Expand All @@ -27,7 +27,7 @@ static String encode(UUID uuid) {
*/
static UUID decode(String id) {
BigInteger decoded = Base62.decode(id);
return UuidConverter.toUuid(decoded);
return UuidConverter.toUuid(decoded, FriendlyIds.getEncoding());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@

class UuidConverter {

static BigInteger toBigInteger(UUID uuid) {
return BigIntegerPairing.pair(
static BigInteger toBigInteger(UUID uuid, FriendlyIdEncoding encoding) {
return encoding.pair(
BigInteger.valueOf(uuid.getMostSignificantBits()),
BigInteger.valueOf(uuid.getLeastSignificantBits())
);
}

static UUID toUuid(BigInteger value) {
BigInteger[] unpaired = BigIntegerPairing.unpair(value);
static UUID toUuid(BigInteger value, FriendlyIdEncoding encoding) {
BigInteger[] unpaired = encoding.unpair(value);
return new UUID(unpaired[0].longValueExact(), unpaired[1].longValueExact());
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ class AnalyzeGeneratedIdsTest {
@Test
void analyzeGeneratedValueStatistics() {
for (int i = 0; i < 100_000; i++) {
this.ids.add(Base62.encode(UuidConverter.toBigInteger(UUID.randomUUID())));
this.ids.add(Base62.encode(UuidConverter.toBigInteger(UUID.randomUUID(), FriendlyIds.getEncoding())));
}
IntSummaryStatistics stats = ids.stream().map(String::length).mapToInt(Integer::intValue).summaryStatistics();

Expand Down
Loading
Loading