Changed to ULID submission ids.
This commit is contained in:
parent
a8168768a8
commit
617bd45127
|
@ -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.
|
||||
|
|
|
@ -5,7 +5,7 @@
|
|||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.0.1</version>
|
||||
<version>3.0.2</version>
|
||||
<relativePath/> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>nl.andrewlalis</groupId>
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -8,6 +8,6 @@ import org.springframework.stereotype.Repository;
|
|||
import java.util.List;
|
||||
|
||||
@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);
|
||||
}
|
||||
|
|
|
@ -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<ExerciseSubmissionVideoFile, Long> {
|
||||
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);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<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)
|
||||
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 {
|
|||
* </p>
|
||||
* @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<ExerciseSubmission> 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())) {
|
||||
|
|
|
@ -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!");
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue