From 617bd451279d6e0299a920e5140ba1dcdbd84c07 Mon Sep 17 00:00:00 2001
From: Andrew Lalis
Date: Fri, 27 Jan 2023 20:41:39 +0100
Subject: [PATCH] Changed to ULID submission ids.
---
gymboard-api/README.md | 4 +
gymboard-api/pom.xml | 2 +-
.../gymboard_api/config/WebConfig.java | 9 +-
.../controller/FileController.java | 30 --
.../controller/GymController.java | 15 -
.../controller/SubmissionController.java | 29 ++
.../dto/ExerciseSubmissionResponse.java | 2 +-
.../ExerciseSubmissionRepository.java | 2 +-
...ExerciseSubmissionVideoFileRepository.java | 5 +
.../model/exercise/ExerciseSubmission.java | 26 +-
.../service/ExerciseSubmissionService.java | 36 +-
.../andrewlalis/gymboard_api/util/ULID.java | 456 ++++++++++++++++++
12 files changed, 539 insertions(+), 77 deletions(-)
delete mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java
create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/SubmissionController.java
create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ULID.java
diff --git a/gymboard-api/README.md b/gymboard-api/README.md
index 827ec32..343e41b 100644
--- a/gymboard-api/README.md
+++ b/gymboard-api/README.md
@@ -5,3 +5,7 @@ An HTTP/REST API powered by Java and Spring Boot. This API serves as the main en
## Development
To ease development, `nl.andrewlalis.gymboard_api.model.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory.
+
+## ULIDs
+
+For entities that don't need a human-readable primary key (or keys), we choose to use [ULID](https://github.com/ulid/spec) strings, which are like UUIDs, but use a timestamp based preamble such that their values are monotonically increasing, and lexicographically ordered by creation time. The result is a pseudorandom string of 26 characters which appears random to a human, yet is efficient as a primary key. It's also near-impossible for automated systems to guess previous/next ids.
diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml
index 5f1d41a..c1e6970 100644
--- a/gymboard-api/pom.xml
+++ b/gymboard-api/pom.xml
@@ -5,7 +5,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.0.1
+ 3.0.2
nl.andrewlalis
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java
index 6563483..e1bbd40 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java
@@ -1,16 +1,18 @@
package nl.andrewlalis.gymboard_api.config;
+import nl.andrewlalis.gymboard_api.util.ULID;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
+import java.time.OffsetDateTime;
import java.util.Arrays;
@Configuration
public class WebConfig {
-
@Bean
public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@@ -23,4 +25,9 @@ public class WebConfig {
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
+
+ @Bean
+ public ULID ulid() {
+ return new ULID();
+ }
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java
deleted file mode 100644
index a54e934..0000000
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/FileController.java
+++ /dev/null
@@ -1,30 +0,0 @@
-package nl.andrewlalis.gymboard_api.controller;
-
-import jakarta.servlet.http.HttpServletResponse;
-import nl.andrewlalis.gymboard_api.dao.StoredFileRepository;
-import nl.andrewlalis.gymboard_api.model.StoredFile;
-import org.springframework.http.HttpStatus;
-import org.springframework.web.bind.annotation.GetMapping;
-import org.springframework.web.bind.annotation.PathVariable;
-import org.springframework.web.bind.annotation.RestController;
-import org.springframework.web.server.ResponseStatusException;
-
-import java.io.IOException;
-
-@RestController
-public class FileController {
- private final StoredFileRepository fileRepository;
-
- public FileController(StoredFileRepository fileRepository) {
- this.fileRepository = fileRepository;
- }
-
- @GetMapping(path = "/files/{fileId}")
- public void getFile(@PathVariable long fileId, HttpServletResponse response) throws IOException {
- StoredFile file = fileRepository.findById(fileId)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- response.setContentType(file.getMimeType());
- response.setContentLengthLong(file.getSize());
- response.getOutputStream().write(file.getContent());
- }
-}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java
index 7dcb40f..163b2a8 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java
@@ -1,6 +1,5 @@
package nl.andrewlalis.gymboard_api.controller;
-import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.*;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.GymService;
@@ -45,11 +44,6 @@ public class GymController {
return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload);
}
- @GetMapping(path = "/submissions/{submissionId}")
- public ExerciseSubmissionResponse getSubmission(@PathVariable String compoundId, @PathVariable long submissionId) {
- return submissionService.getSubmission(CompoundGymId.parse(compoundId), submissionId);
- }
-
@PostMapping(path = "/submissions/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadedFileResponse uploadVideo(
@PathVariable String compoundId,
@@ -57,13 +51,4 @@ public class GymController {
) {
return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), file);
}
-
- @GetMapping(path = "/submissions/{submissionId}/video")
- public void getSubmissionVideo(
- @PathVariable String compoundId,
- @PathVariable long submissionId,
- HttpServletResponse response
- ) {
- submissionService.streamVideo(CompoundGymId.parse(compoundId), submissionId, response);
- }
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/SubmissionController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/SubmissionController.java
new file mode 100644
index 0000000..7ca11ce
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/SubmissionController.java
@@ -0,0 +1,29 @@
+package nl.andrewlalis.gymboard_api.controller;
+
+import jakarta.servlet.http.HttpServletResponse;
+import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse;
+import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping(path = "/submissions")
+public class SubmissionController {
+ private final ExerciseSubmissionService submissionService;
+
+ public SubmissionController(ExerciseSubmissionService submissionService) {
+ this.submissionService = submissionService;
+ }
+
+ @GetMapping(path = "/{submissionId}")
+ public ExerciseSubmissionResponse getSubmission(@PathVariable String submissionId) {
+ return submissionService.getSubmission(submissionId);
+ }
+
+ @GetMapping(path = "/{submissionId}/video")
+ public void getSubmissionVideo(@PathVariable String submissionId, HttpServletResponse response) {
+ submissionService.streamVideo(submissionId, response);
+ }
+}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java
index 2abe6ac..891556a 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/ExerciseSubmissionResponse.java
@@ -5,7 +5,7 @@ import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import java.time.format.DateTimeFormatter;
public record ExerciseSubmissionResponse(
- long id,
+ String id,
String createdAt,
GymSimpleResponse gym,
ExerciseResponse exercise,
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java
index b0277cc..3f86920 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionRepository.java
@@ -8,6 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List;
@Repository
-public interface ExerciseSubmissionRepository extends JpaRepository, JpaSpecificationExecutor {
+public interface ExerciseSubmissionRepository extends JpaRepository, JpaSpecificationExecutor {
List findAllByStatus(ExerciseSubmission.Status status);
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java
index 55fc9ce..4a47ba5 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/exercise/ExerciseSubmissionVideoFileRepository.java
@@ -3,6 +3,7 @@ package nl.andrewlalis.gymboard_api.dao.exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@@ -10,4 +11,8 @@ import java.util.Optional;
@Repository
public interface ExerciseSubmissionVideoFileRepository extends JpaRepository {
Optional findBySubmission(ExerciseSubmission submission);
+
+ @Query("SELECT f FROM ExerciseSubmissionVideoFile f WHERE " +
+ "f.submission.id = :submissionId AND f.submission.complete = true")
+ Optional findByCompletedSubmissionId(String submissionId);
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java
index e047f09..58a7e00 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/exercise/ExerciseSubmission.java
@@ -34,8 +34,8 @@ public class ExerciseSubmission {
}
@Id
- @GeneratedValue(strategy = GenerationType.IDENTITY)
- private Long id;
+ @Column(nullable = false, updatable = false, length = 26)
+ private String id;
@CreationTimestamp
private LocalDateTime createdAt;
@@ -66,9 +66,18 @@ public class ExerciseSubmission {
@Column(nullable = false)
private int reps;
+ /**
+ * Marker that's used to simplify queries where we just want submissions
+ * that are in a status that's not WAITING, PROCESSING, or FAILED, i.e.
+ * a successful submission that's been processed.
+ */
+ @Column(nullable = false)
+ private boolean complete;
+
public ExerciseSubmission() {}
- public ExerciseSubmission(Gym gym, Exercise exercise, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
+ public ExerciseSubmission(String id, Gym gym, Exercise exercise, String submitterName, BigDecimal rawWeight, WeightUnit unit, BigDecimal metricWeight, int reps) {
+ this.id = id;
this.gym = gym;
this.exercise = exercise;
this.submitterName = submitterName;
@@ -77,9 +86,10 @@ public class ExerciseSubmission {
this.metricWeight = metricWeight;
this.reps = reps;
this.status = Status.WAITING;
+ this.complete = false;
}
- public Long getId() {
+ public String getId() {
return id;
}
@@ -122,4 +132,12 @@ public class ExerciseSubmission {
public int getReps() {
return reps;
}
+
+ public boolean isComplete() {
+ return complete;
+ }
+
+ public void setComplete(boolean complete) {
+ this.complete = complete;
+ }
}
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java
index ad3abf1..4bb85d0 100644
--- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/ExerciseSubmissionService.java
@@ -16,6 +16,7 @@ import nl.andrewlalis.gymboard_api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile;
+import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
@@ -33,7 +34,6 @@ import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
-import java.util.Set;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
@@ -52,6 +52,7 @@ public class ExerciseSubmissionService {
private final ExerciseSubmissionTempFileRepository tempFileRepository;
private final ExerciseSubmissionVideoFileRepository submissionVideoFileRepository;
private final Executor taskExecutor;
+ private final ULID ulid;
public ExerciseSubmissionService(GymRepository gymRepository,
StoredFileRepository fileRepository,
@@ -59,8 +60,7 @@ public class ExerciseSubmissionService {
ExerciseSubmissionRepository exerciseSubmissionRepository,
ExerciseSubmissionTempFileRepository tempFileRepository,
ExerciseSubmissionVideoFileRepository submissionVideoFileRepository,
- Executor taskExecutor
- ) {
+ Executor taskExecutor, ULID ulid) {
this.gymRepository = gymRepository;
this.fileRepository = fileRepository;
this.exerciseRepository = exerciseRepository;
@@ -68,34 +68,19 @@ public class ExerciseSubmissionService {
this.tempFileRepository = tempFileRepository;
this.submissionVideoFileRepository = submissionVideoFileRepository;
this.taskExecutor = taskExecutor;
+ this.ulid = ulid;
}
@Transactional(readOnly = true)
- public ExerciseSubmissionResponse getSubmission(CompoundGymId id, long submissionId) {
- Gym gym = gymRepository.findByCompoundId(id)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
+ public ExerciseSubmissionResponse getSubmission(String submissionId) {
ExerciseSubmission submission = exerciseSubmissionRepository.findById(submissionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- if (!submission.getGym().getId().equals(gym.getId())) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return new ExerciseSubmissionResponse(submission);
}
@Transactional(readOnly = true)
- public void streamVideo(CompoundGymId compoundId, long submissionId, HttpServletResponse response) {
- // TODO: Make a faster way to stream videos, should be one DB call instead of this mess.
- Gym gym = gymRepository.findByCompoundId(compoundId)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- ExerciseSubmission submission = exerciseSubmissionRepository.findById(submissionId)
- .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
- if (!submission.getGym().getId().equals(gym.getId())) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
- Set validStatuses = Set.of(
- ExerciseSubmission.Status.COMPLETED,
- ExerciseSubmission.Status.VERIFIED
- );
- if (!validStatuses.contains(submission.getStatus())) {
- throw new ResponseStatusException(HttpStatus.NOT_FOUND);
- }
- ExerciseSubmissionVideoFile videoFile = submissionVideoFileRepository.findBySubmission(submission)
+ public void streamVideo(String submissionId, HttpServletResponse response) {
+ ExerciseSubmissionVideoFile videoFile = submissionVideoFileRepository.findByCompletedSubmissionId(submissionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
response.setContentType(videoFile.getFile().getMimeType());
response.setContentLengthLong(videoFile.getFile().getSize());
@@ -140,7 +125,8 @@ public class ExerciseSubmissionService {
metricWeight = metricWeight.multiply(new BigDecimal("0.45359237"));
}
- ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission(
+ ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission(
+ ulid.nextULID(),
gym,
exercise,
payload.name(),
@@ -179,7 +165,7 @@ public class ExerciseSubmissionService {
*
* @param submissionId The submission's id.
*/
- private void processSubmission(long submissionId) {
+ private void processSubmission(String submissionId) {
log.info("Starting processing of submission {}.", submissionId);
// First try and fetch the submission.
Optional optionalSubmission = exerciseSubmissionRepository.findById(submissionId);
@@ -250,6 +236,7 @@ public class ExerciseSubmissionService {
file
));
submission.setStatus(ExerciseSubmission.Status.COMPLETED);
+ submission.setComplete(true);
exerciseSubmissionRepository.save(submission);
// And delete the temporary files.
try {
@@ -323,6 +310,7 @@ public class ExerciseSubmissionService {
}
// Then remove any files in the directory which don't correspond to a valid file in the db.
+ if (Files.notExists(UploadService.SUBMISSION_TEMP_FILE_DIR)) return;
try (var s = Files.list(UploadService.SUBMISSION_TEMP_FILE_DIR)) {
for (var path : s.toList()) {
if (!tempFileRepository.existsByPath(path.toString())) {
diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ULID.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ULID.java
new file mode 100644
index 0000000..4db225a
--- /dev/null
+++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/util/ULID.java
@@ -0,0 +1,456 @@
+package nl.andrewlalis.gymboard_api.util;
+
+/*
+ * sulky-modules - several general-purpose modules.
+ * Copyright (C) 2007-2019 Joern Huxhorn
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Lesser General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program. If not, see .
+ */
+
+/*
+ * Copyright 2007-2019 Joern Huxhorn
+ *
+ * Licensed 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.
+ */
+
+import java.io.Serializable;
+import java.security.SecureRandom;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Random;
+
+/*
+ * https://github.com/ulid/spec
+ */
+@SuppressWarnings("PMD.ShortClassName")
+public class ULID
+{
+ private static final char[] ENCODING_CHARS = {
+ '0','1','2','3','4','5','6','7','8','9',
+ 'A','B','C','D','E','F','G','H','J','K',
+ 'M','N','P','Q','R','S','T','V','W','X',
+ 'Y','Z',
+ };
+
+ private static final byte[] DECODING_CHARS = {
+ // 0
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 8
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 16
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 24
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 32
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 40
+ -1, -1, -1, -1, -1, -1, -1, -1,
+ // 48
+ 0, 1, 2, 3, 4, 5, 6, 7,
+ // 56
+ 8, 9, -1, -1, -1, -1, -1, -1,
+ // 64
+ -1, 10, 11, 12, 13, 14, 15, 16,
+ // 72
+ 17, 1, 18, 19, 1, 20, 21, 0,
+ // 80
+ 22, 23, 24, 25, 26, -1, 27, 28,
+ // 88
+ 29, 30, 31, -1, -1, -1, -1, -1,
+ // 96
+ -1, 10, 11, 12, 13, 14, 15, 16,
+ // 104
+ 17, 1, 18, 19, 1, 20, 21, 0,
+ // 112
+ 22, 23, 24, 25, 26, -1, 27, 28,
+ // 120
+ 29, 30, 31,
+ };
+
+ private static final int MASK = 0x1F;
+ private static final int MASK_BITS = 5;
+ private static final long TIMESTAMP_OVERFLOW_MASK = 0xFFFF_0000_0000_0000L;
+ private static final long TIMESTAMP_MSB_MASK = 0xFFFF_FFFF_FFFF_0000L;
+ private static final long RANDOM_MSB_MASK = 0xFFFFL;
+
+ private final Random random;
+
+ public ULID()
+ {
+ this(new SecureRandom());
+ }
+
+ public ULID(Random random)
+ {
+ Objects.requireNonNull(random, "random must not be null!");
+ this.random = random;
+ }
+
+ public void appendULID(StringBuilder stringBuilder)
+ {
+ Objects.requireNonNull(stringBuilder, "stringBuilder must not be null!");
+ internalAppendULID(stringBuilder, System.currentTimeMillis(), random);
+ }
+
+ public String nextULID()
+ {
+ return nextULID(System.currentTimeMillis());
+ }
+
+ public String nextULID(long timestamp)
+ {
+ return internalUIDString(timestamp, random);
+ }
+
+ public Value nextValue()
+ {
+ return nextValue(System.currentTimeMillis());
+ }
+
+ public Value nextValue(long timestamp)
+ {
+ return internalNextValue(timestamp, random);
+ }
+
+ /**
+ * Returns the next monotonic value. If an overflow happened while incrementing
+ * the random part of the given previous ULID value then the returned value will
+ * have a zero random part.
+ *
+ * @param previousUlid the previous ULID value.
+ * @return the next monotonic value.
+ */
+ public Value nextMonotonicValue(Value previousUlid)
+ {
+ return nextMonotonicValue(previousUlid, System.currentTimeMillis());
+ }
+
+ /**
+ * Returns the next monotonic value. If an overflow happened while incrementing
+ * the random part of the given previous ULID value then the returned value will
+ * have a zero random part.
+ *
+ * @param previousUlid the previous ULID value.
+ * @param timestamp the timestamp of the next ULID value.
+ * @return the next monotonic value.
+ */
+ public Value nextMonotonicValue(Value previousUlid, long timestamp)
+ {
+ Objects.requireNonNull(previousUlid, "previousUlid must not be null!");
+ if(previousUlid.timestamp() == timestamp)
+ {
+ return previousUlid.increment();
+ }
+ return nextValue(timestamp);
+ }
+
+ /**
+ * Returns the next monotonic value or empty if an overflow happened while incrementing
+ * the random part of the given previous ULID value.
+ *
+ * @param previousUlid the previous ULID value.
+ * @return the next monotonic value or empty if an overflow happened.
+ */
+ public Optional nextStrictlyMonotonicValue(Value previousUlid)
+ {
+ return nextStrictlyMonotonicValue(previousUlid, System.currentTimeMillis());
+ }
+
+ /**
+ * Returns the next monotonic value or empty if an overflow happened while incrementing
+ * the random part of the given previous ULID value.
+ *
+ * @param previousUlid the previous ULID value.
+ * @param timestamp the timestamp of the next ULID value.
+ * @return the next monotonic value or empty if an overflow happened.
+ */
+ public Optional nextStrictlyMonotonicValue(Value previousUlid, long timestamp)
+ {
+ Value result = nextMonotonicValue(previousUlid, timestamp);
+ if(result.compareTo(previousUlid) < 1)
+ {
+ return Optional.empty();
+ }
+ return Optional.of(result);
+ }
+
+ public static Value parseULID(String ulidString)
+ {
+ Objects.requireNonNull(ulidString, "ulidString must not be null!");
+ if(ulidString.length() != 26)
+ {
+ throw new IllegalArgumentException("ulidString must be exactly 26 chars long.");
+ }
+
+ String timeString = ulidString.substring(0, 10);
+ long time = internalParseCrockford(timeString);
+ if ((time & TIMESTAMP_OVERFLOW_MASK) != 0)
+ {
+ throw new IllegalArgumentException("ulidString must not exceed '7ZZZZZZZZZZZZZZZZZZZZZZZZZ'!");
+ }
+ String part1String = ulidString.substring(10, 18);
+ String part2String = ulidString.substring(18);
+ long part1 = internalParseCrockford(part1String);
+ long part2 = internalParseCrockford(part2String);
+
+ long most = (time << 16) | (part1 >>> 24);
+ long least = part2 | (part1 << 40);
+ return new Value(most, least);
+ }
+
+ public static Value fromBytes(byte[] data)
+ {
+ Objects.requireNonNull(data, "data must not be null!");
+ if(data.length != 16)
+ {
+ throw new IllegalArgumentException("data must be 16 bytes in length!");
+ }
+ long mostSignificantBits = 0;
+ long leastSignificantBits = 0;
+ for (int i=0; i<8; i++)
+ {
+ mostSignificantBits = (mostSignificantBits << 8) | (data[i] & 0xff);
+ }
+ for (int i=8; i<16; i++)
+ {
+ leastSignificantBits = (leastSignificantBits << 8) | (data[i] & 0xff);
+ }
+ return new Value(mostSignificantBits, leastSignificantBits);
+ }
+
+ public static class Value
+ implements Comparable, Serializable
+ {
+ private static final long serialVersionUID = -3563159514112487717L;
+
+ /*
+ * The most significant 64 bits of this ULID.
+ */
+ private final long mostSignificantBits;
+
+ /*
+ * The least significant 64 bits of this ULID.
+ */
+ private final long leastSignificantBits;
+
+ public Value(long mostSignificantBits, long leastSignificantBits)
+ {
+ this.mostSignificantBits = mostSignificantBits;
+ this.leastSignificantBits = leastSignificantBits;
+ }
+
+ /**
+ * Returns the most significant 64 bits of this ULID's 128 bit value.
+ *
+ * @return The most significant 64 bits of this ULID's 128 bit value
+ */
+ public long getMostSignificantBits() {
+ return mostSignificantBits;
+ }
+
+ /**
+ * Returns the least significant 64 bits of this ULID's 128 bit value.
+ *
+ * @return The least significant 64 bits of this ULID's 128 bit value
+ */
+ public long getLeastSignificantBits() {
+ return leastSignificantBits;
+ }
+
+
+ public long timestamp()
+ {
+ return mostSignificantBits >>> 16;
+ }
+
+ public byte[] toBytes()
+ {
+ byte[] result=new byte[16];
+ for (int i=0; i<8; i++)
+ {
+ result[i] = (byte)((mostSignificantBits >> ((7-i)*8)) & 0xFF);
+ }
+ for (int i=8; i<16; i++)
+ {
+ result[i] = (byte)((leastSignificantBits >> ((15-i)*8)) & 0xFF);
+ }
+
+ return result;
+ }
+
+ public Value increment()
+ {
+ long lsb = leastSignificantBits;
+ if(lsb != 0xFFFF_FFFF_FFFF_FFFFL)
+ {
+ return new Value(mostSignificantBits, lsb+1);
+ }
+ long msb = mostSignificantBits;
+ if((msb & RANDOM_MSB_MASK) != RANDOM_MSB_MASK)
+ {
+ return new Value(msb + 1, 0);
+ }
+ return new Value(msb & TIMESTAMP_MSB_MASK, 0);
+ }
+
+ @Override
+ public int hashCode() {
+ long hilo = mostSignificantBits ^ leastSignificantBits;
+ return ((int)(hilo >> 32)) ^ (int) hilo;
+ }
+
+ @Override
+ public boolean equals(Object o)
+ {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+
+ Value value = (Value) o;
+
+ return mostSignificantBits == value.mostSignificantBits
+ && leastSignificantBits == value.leastSignificantBits;
+ }
+
+ @Override
+ public int compareTo(Value val)
+ {
+ // The ordering is intentionally set up so that the ULIDs
+ // can simply be numerically compared as two numbers
+ return (this.mostSignificantBits < val.mostSignificantBits ? -1 :
+ (this.mostSignificantBits > val.mostSignificantBits ? 1 :
+ (this.leastSignificantBits < val.leastSignificantBits ? -1 :
+ (this.leastSignificantBits > val.leastSignificantBits ? 1 :
+ 0))));
+ }
+
+ @Override
+ public String toString()
+ {
+ char[] buffer = new char[26];
+
+ internalWriteCrockford(buffer, timestamp(), 10, 0);
+ long value = ((mostSignificantBits & 0xFFFFL) << 24);
+ long interim = (leastSignificantBits >>> 40);
+ value = value | interim;
+ internalWriteCrockford(buffer, value, 8, 10);
+ internalWriteCrockford(buffer, leastSignificantBits, 8, 18);
+
+ return new String(buffer);
+ }
+ }
+
+ /*
+ * http://crockford.com/wrmg/base32.html
+ */
+ static void internalAppendCrockford(StringBuilder builder, long value, int count)
+ {
+ for(int i = count-1; i >= 0; i--)
+ {
+ int index = (int)((value >>> (i * MASK_BITS)) & MASK);
+ builder.append(ENCODING_CHARS[index]);
+ }
+ }
+
+ static long internalParseCrockford(String input)
+ {
+ Objects.requireNonNull(input, "input must not be null!");
+ int length = input.length();
+ if(length > 12)
+ {
+ throw new IllegalArgumentException("input length must not exceed 12 but was "+length+"!");
+ }
+
+ long result = 0;
+ for(int i=0;i>> ((count - i - 1) * MASK_BITS)) & MASK);
+ buffer[offset+i] = ENCODING_CHARS[index];
+ }
+ }
+
+ static String internalUIDString(long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+
+ char[] buffer = new char[26];
+
+ internalWriteCrockford(buffer, timestamp, 10, 0);
+ // could use nextBytes(byte[] bytes) instead
+ internalWriteCrockford(buffer, random.nextLong(), 8, 10);
+ internalWriteCrockford(buffer, random.nextLong(), 8, 18);
+
+ return new String(buffer);
+ }
+
+ static void internalAppendULID(StringBuilder builder, long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+
+ internalAppendCrockford(builder, timestamp, 10);
+ // could use nextBytes(byte[] bytes) instead
+ internalAppendCrockford(builder, random.nextLong(), 8);
+ internalAppendCrockford(builder, random.nextLong(), 8);
+ }
+
+ static Value internalNextValue(long timestamp, Random random)
+ {
+ checkTimestamp(timestamp);
+ // could use nextBytes(byte[] bytes) instead
+ long mostSignificantBits = random.nextLong();
+ long leastSignificantBits = random.nextLong();
+ mostSignificantBits &= 0xFFFF;
+ mostSignificantBits |= (timestamp << 16);
+ return new Value(mostSignificantBits, leastSignificantBits);
+ }
+
+ private static void checkTimestamp(long timestamp)
+ {
+ if((timestamp & TIMESTAMP_OVERFLOW_MASK) != 0)
+ {
+ throw new IllegalArgumentException("ULID does not support timestamps after +10889-08-02T05:31:50.655Z!");
+ }
+ }
+}