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!"); + } + } +}