Changed to ULID submission ids.

This commit is contained in:
Andrew Lalis 2023-01-27 20:41:39 +01:00
parent a8168768a8
commit 617bd45127
12 changed files with 539 additions and 77 deletions

View File

@ -5,3 +5,7 @@ An HTTP/REST API powered by Java and Spring Boot. This API serves as the main en
## Development ## 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. 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.

View File

@ -5,7 +5,7 @@
<parent> <parent>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId> <artifactId>spring-boot-starter-parent</artifactId>
<version>3.0.1</version> <version>3.0.2</version>
<relativePath/> <!-- lookup parent from repository --> <relativePath/> <!-- lookup parent from repository -->
</parent> </parent>
<groupId>nl.andrewlalis</groupId> <groupId>nl.andrewlalis</groupId>

View File

@ -1,16 +1,18 @@
package nl.andrewlalis.gymboard_api.config; 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.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.CorsFilter;
import java.time.OffsetDateTime;
import java.util.Arrays; import java.util.Arrays;
@Configuration @Configuration
public class WebConfig { public class WebConfig {
@Bean @Bean
public CorsFilter corsFilter() { public CorsFilter corsFilter() {
final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
@ -23,4 +25,9 @@ public class WebConfig {
source.registerCorsConfiguration("/**", config); source.registerCorsConfiguration("/**", config);
return new CorsFilter(source); return new CorsFilter(source);
} }
@Bean
public ULID ulid() {
return new ULID();
}
} }

View File

@ -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());
}
}

View File

@ -1,6 +1,5 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.controller;
import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.controller.dto.*; import nl.andrewlalis.gymboard_api.controller.dto.*;
import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.service.ExerciseSubmissionService;
import nl.andrewlalis.gymboard_api.service.GymService; import nl.andrewlalis.gymboard_api.service.GymService;
@ -45,11 +44,6 @@ public class GymController {
return submissionService.createSubmission(CompoundGymId.parse(compoundId), payload); 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) @PostMapping(path = "/submissions/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public UploadedFileResponse uploadVideo( public UploadedFileResponse uploadVideo(
@PathVariable String compoundId, @PathVariable String compoundId,
@ -57,13 +51,4 @@ public class GymController {
) { ) {
return uploadService.handleSubmissionUpload(CompoundGymId.parse(compoundId), file); 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);
}
} }

View File

@ -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);
}
}

View File

@ -5,7 +5,7 @@ import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
public record ExerciseSubmissionResponse( public record ExerciseSubmissionResponse(
long id, String id,
String createdAt, String createdAt,
GymSimpleResponse gym, GymSimpleResponse gym,
ExerciseResponse exercise, ExerciseResponse exercise,

View File

@ -8,6 +8,6 @@ import org.springframework.stereotype.Repository;
import java.util.List; import java.util.List;
@Repository @Repository
public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, Long>, JpaSpecificationExecutor<ExerciseSubmission> { public interface ExerciseSubmissionRepository extends JpaRepository<ExerciseSubmission, String>, JpaSpecificationExecutor<ExerciseSubmission> {
List<ExerciseSubmission> findAllByStatus(ExerciseSubmission.Status status); List<ExerciseSubmission> findAllByStatus(ExerciseSubmission.Status status);
} }

View File

@ -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.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.util.Optional; import java.util.Optional;
@ -10,4 +11,8 @@ import java.util.Optional;
@Repository @Repository
public interface ExerciseSubmissionVideoFileRepository extends JpaRepository<ExerciseSubmissionVideoFile, Long> { public interface ExerciseSubmissionVideoFileRepository extends JpaRepository<ExerciseSubmissionVideoFile, Long> {
Optional<ExerciseSubmissionVideoFile> findBySubmission(ExerciseSubmission submission); Optional<ExerciseSubmissionVideoFile> findBySubmission(ExerciseSubmission submission);
@Query("SELECT f FROM ExerciseSubmissionVideoFile f WHERE " +
"f.submission.id = :submissionId AND f.submission.complete = true")
Optional<ExerciseSubmissionVideoFile> findByCompletedSubmissionId(String submissionId);
} }

View File

@ -34,8 +34,8 @@ public class ExerciseSubmission {
} }
@Id @Id
@GeneratedValue(strategy = GenerationType.IDENTITY) @Column(nullable = false, updatable = false, length = 26)
private Long id; private String id;
@CreationTimestamp @CreationTimestamp
private LocalDateTime createdAt; private LocalDateTime createdAt;
@ -66,9 +66,18 @@ public class ExerciseSubmission {
@Column(nullable = false) @Column(nullable = false)
private int reps; 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() {}
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.gym = gym;
this.exercise = exercise; this.exercise = exercise;
this.submitterName = submitterName; this.submitterName = submitterName;
@ -77,9 +86,10 @@ public class ExerciseSubmission {
this.metricWeight = metricWeight; this.metricWeight = metricWeight;
this.reps = reps; this.reps = reps;
this.status = Status.WAITING; this.status = Status.WAITING;
this.complete = false;
} }
public Long getId() { public String getId() {
return id; return id;
} }
@ -122,4 +132,12 @@ public class ExerciseSubmission {
public int getReps() { public int getReps() {
return reps; return reps;
} }
public boolean isComplete() {
return complete;
}
public void setComplete(boolean complete) {
this.complete = complete;
}
} }

View File

@ -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.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionTempFile;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmissionVideoFile;
import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -33,7 +34,6 @@ import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
@ -52,6 +52,7 @@ public class ExerciseSubmissionService {
private final ExerciseSubmissionTempFileRepository tempFileRepository; private final ExerciseSubmissionTempFileRepository tempFileRepository;
private final ExerciseSubmissionVideoFileRepository submissionVideoFileRepository; private final ExerciseSubmissionVideoFileRepository submissionVideoFileRepository;
private final Executor taskExecutor; private final Executor taskExecutor;
private final ULID ulid;
public ExerciseSubmissionService(GymRepository gymRepository, public ExerciseSubmissionService(GymRepository gymRepository,
StoredFileRepository fileRepository, StoredFileRepository fileRepository,
@ -59,8 +60,7 @@ public class ExerciseSubmissionService {
ExerciseSubmissionRepository exerciseSubmissionRepository, ExerciseSubmissionRepository exerciseSubmissionRepository,
ExerciseSubmissionTempFileRepository tempFileRepository, ExerciseSubmissionTempFileRepository tempFileRepository,
ExerciseSubmissionVideoFileRepository submissionVideoFileRepository, ExerciseSubmissionVideoFileRepository submissionVideoFileRepository,
Executor taskExecutor Executor taskExecutor, ULID ulid) {
) {
this.gymRepository = gymRepository; this.gymRepository = gymRepository;
this.fileRepository = fileRepository; this.fileRepository = fileRepository;
this.exerciseRepository = exerciseRepository; this.exerciseRepository = exerciseRepository;
@ -68,34 +68,19 @@ public class ExerciseSubmissionService {
this.tempFileRepository = tempFileRepository; this.tempFileRepository = tempFileRepository;
this.submissionVideoFileRepository = submissionVideoFileRepository; this.submissionVideoFileRepository = submissionVideoFileRepository;
this.taskExecutor = taskExecutor; this.taskExecutor = taskExecutor;
this.ulid = ulid;
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public ExerciseSubmissionResponse getSubmission(CompoundGymId id, long submissionId) { public ExerciseSubmissionResponse getSubmission(String submissionId) {
Gym gym = gymRepository.findByCompoundId(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
ExerciseSubmission submission = exerciseSubmissionRepository.findById(submissionId) ExerciseSubmission submission = exerciseSubmissionRepository.findById(submissionId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!submission.getGym().getId().equals(gym.getId())) throw new ResponseStatusException(HttpStatus.NOT_FOUND);
return new ExerciseSubmissionResponse(submission); return new ExerciseSubmissionResponse(submission);
} }
@Transactional(readOnly = true) @Transactional(readOnly = true)
public void streamVideo(CompoundGymId compoundId, long submissionId, HttpServletResponse response) { public void streamVideo(String submissionId, HttpServletResponse response) {
// TODO: Make a faster way to stream videos, should be one DB call instead of this mess. ExerciseSubmissionVideoFile videoFile = submissionVideoFileRepository.findByCompletedSubmissionId(submissionId)
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<ExerciseSubmission.Status> 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)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
response.setContentType(videoFile.getFile().getMimeType()); response.setContentType(videoFile.getFile().getMimeType());
response.setContentLengthLong(videoFile.getFile().getSize()); response.setContentLengthLong(videoFile.getFile().getSize());
@ -140,7 +125,8 @@ public class ExerciseSubmissionService {
metricWeight = metricWeight.multiply(new BigDecimal("0.45359237")); metricWeight = metricWeight.multiply(new BigDecimal("0.45359237"));
} }
ExerciseSubmission submission = exerciseSubmissionRepository.save(new ExerciseSubmission( ExerciseSubmission submission = exerciseSubmissionRepository.saveAndFlush(new ExerciseSubmission(
ulid.nextULID(),
gym, gym,
exercise, exercise,
payload.name(), payload.name(),
@ -179,7 +165,7 @@ public class ExerciseSubmissionService {
* </p> * </p>
* @param submissionId The submission's id. * @param submissionId The submission's id.
*/ */
private void processSubmission(long submissionId) { private void processSubmission(String submissionId) {
log.info("Starting processing of submission {}.", submissionId); log.info("Starting processing of submission {}.", submissionId);
// First try and fetch the submission. // First try and fetch the submission.
Optional<ExerciseSubmission> optionalSubmission = exerciseSubmissionRepository.findById(submissionId); Optional<ExerciseSubmission> optionalSubmission = exerciseSubmissionRepository.findById(submissionId);
@ -250,6 +236,7 @@ public class ExerciseSubmissionService {
file file
)); ));
submission.setStatus(ExerciseSubmission.Status.COMPLETED); submission.setStatus(ExerciseSubmission.Status.COMPLETED);
submission.setComplete(true);
exerciseSubmissionRepository.save(submission); exerciseSubmissionRepository.save(submission);
// And delete the temporary files. // And delete the temporary files.
try { 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. // 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)) { try (var s = Files.list(UploadService.SUBMISSION_TEMP_FILE_DIR)) {
for (var path : s.toList()) { for (var path : s.toList()) {
if (!tempFileRepository.existsByPath(path.toString())) { if (!tempFileRepository.existsByPath(path.toString())) {

View File

@ -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 <http://www.gnu.org/licenses/>.
*/
/*
* 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<Value> 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<Value> 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<Value>, 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<length;i++)
{
char current = input.charAt(i);
byte value = -1;
if(current < DECODING_CHARS.length)
{
value = DECODING_CHARS[current];
}
if(value < 0)
{
throw new IllegalArgumentException("Illegal character '"+current+"'!");
}
result |= ((long)value) << ((length - 1 - i)*MASK_BITS);
}
return result;
}
/*
* http://crockford.com/wrmg/base32.html
*/
static void internalWriteCrockford(char[] buffer, long value, int count, int offset)
{
for(int i = 0; i < count; i++)
{
int index = (int)((value >>> ((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!");
}
}
}