Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0394807
Initial Object store changes. Still need to cleanup.
rma-rripken Dec 23, 2025
d02ccfe
Added methods and util classes to clarify and test http range request…
rma-rripken Dec 24, 2025
ca9f038
Changed range-requests to use more efficient methods.
rma-rripken Jan 3, 2026
de7aded
Removing check for "chunked" from test and looking for Accept-Range a…
rma-rripken Jan 3, 2026
841d910
Return Accept-Range so that browsers will know we support it.
rma-rripken Jan 3, 2026
48b30a8
Use AutoService to have CdaFeatureManagerProvider register as a Featu…
rma-rripken Jan 5, 2026
1b40360
Adding dependencies so that Minio-setup gets run once during docker-c…
rma-rripken Jan 5, 2026
a5216ed
Adding dependencies so that Minio-setup gets run once during docker-c…
rma-rripken Jan 5, 2026
8059c90
Additional logging and checks to make sure minio setup has completed.
rma-rripken Jan 5, 2026
88e6e0c
Running setup script via withCommand to ensure the semicolons are int…
rma-rripken Jan 5, 2026
222d805
Putting Minio and sidecar on same network
rma-rripken Jan 6, 2026
f8df588
Merge branch 'develop' into feature/cda-37-s3_blob_and_clob
rma-rripken Jan 6, 2026
2a8772f
Merge branch 'develop' into feature/cda-37-s3_blob_and_clob
rma-rripken Jan 9, 2026
7bda6c2
Added testcontainers-minio.
rma-rripken Jan 26, 2026
4f77361
Only use Togglz
rma-rripken Jan 26, 2026
04a162a
Start with object-store off by default.
rma-rripken Jan 26, 2026
56de6c5
Remove reference to chunked
rma-rripken Jan 26, 2026
4c95c56
Switching to testcontainers-minio module.
rma-rripken Jan 26, 2026
43ee05f
Switching to testcontainers-minio module.
rma-rripken Jan 26, 2026
e7a5f60
Merge branch 'develop' into feature/cda-37-s3_blob_and_clob
rma-rripken Jan 26, 2026
224d387
Switching to testcontainers-minio module.
rma-rripken Jan 26, 2026
0856dc3
Moved MinIO into its own extension.
rma-rripken Jan 27, 2026
4dffb6c
Removed unused import
rma-rripken Jan 27, 2026
cf02f7b
Merge branch 'develop' into feature/cda-37-s3_blob_and_clob
rma-rripken Jan 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion cwms-data-api/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ dependencies {
implementation(libs.bundles.overrides)

testImplementation(libs.bundles.java.parser)
implementation(libs.togglz.core)
implementation(libs.minio)
}

task extractWebJars(type: Copy) {
Expand Down Expand Up @@ -245,7 +247,7 @@ task run(type: JavaExec) {
}

task integrationTests(type: Test) {
dependsOn test
// dependsOn test
dependsOn generateConfig
dependsOn war

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@
import com.codahale.metrics.Timer;
import cwms.cda.api.errors.CdaError;
import cwms.cda.data.dao.BlobDao;
import cwms.cda.data.dao.StreamConsumer;
import io.javalin.core.util.Header;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import org.jetbrains.annotations.NotNull;
import org.jooq.DSLContext;

import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;

import static com.codahale.metrics.MetricRegistry.name;
import static cwms.cda.api.Controllers.*;
Expand Down Expand Up @@ -84,26 +86,39 @@ private Timer.Context markAndTime(String subject) {
)},
tags = {BinaryTimeSeriesController.TAG}
)
public void handle(Context ctx) {
public void handle(@NotNull Context ctx) {
//Implementation will change with new CWMS schema
//https://www.hec.usace.army.mil/confluence/display/CWMS/2024-02-29+Task2A+Text-ts+and+Binary-ts+Design
try (Timer.Context ignored = markAndTime(GET_ALL)) {
String binaryId = requiredParam(ctx, BLOB_ID);
String officeId = requiredParam(ctx, OFFICE);
DSLContext dsl = getDslContext(ctx);

ctx.header(Header.ACCEPT_RANGES, "bytes");

final Long offset;
final Long end ;
long[] ranges = RangeParser.parseFirstRange(ctx.header(io.javalin.core.util.Header.RANGE));
if (ranges != null) {
offset = ranges[0];
end = ranges[1];
} else {
offset = null;
end = null;
}

BlobDao blobDao = new BlobDao(dsl);
blobDao.getBlob(binaryId, officeId, (blob, mediaType) -> {
if (blob == null) {
StreamConsumer streamConsumer = (is, isPosition, mediaType, totalLength) -> {
if (is == null) {
ctx.status(HttpServletResponse.SC_NOT_FOUND).json(new CdaError("Unable to find "
+ "blob based on given parameters"));
} else {
long size = blob.length();
requestResultSize.update(size);
try (InputStream is = blob.getBinaryStream()) {
RangeRequestUtil.seekableStream(ctx, is, mediaType, size);
}
requestResultSize.update(totalLength);
RangeRequestUtil.seekableStream(ctx, is, isPosition, mediaType, totalLength);
}
});
};

blobDao.getBlob(binaryId, officeId, streamConsumer, offset, end);
}
}
}
201 changes: 119 additions & 82 deletions cwms-data-api/src/main/java/cwms/cda/api/BlobController.java

Large diffs are not rendered by default.

23 changes: 12 additions & 11 deletions cwms-data-api/src/main/java/cwms/cda/api/ClobController.java
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import cwms.cda.api.errors.CdaError;
import cwms.cda.data.dao.ClobDao;
import cwms.cda.data.dao.JooqDao;
import cwms.cda.data.dao.StreamConsumer;
import cwms.cda.data.dto.Clob;
import cwms.cda.data.dto.Clobs;
import cwms.cda.data.dto.CwmsDTOPaginated;
Expand All @@ -43,7 +44,7 @@
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiRequestBody;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import java.io.InputStream;

import java.util.Objects;
import java.util.Optional;
import javax.servlet.http.HttpServletResponse;
Expand Down Expand Up @@ -187,16 +188,16 @@ public void getOne(@NotNull Context ctx, @NotNull String clobId) {
if (TEXT_PLAIN.equals(formatHeader)) {
// useful cmd: curl -X 'GET' 'http://localhost:7000/cwms-data/clobs/encoded?office=SPK&id=%2FTIME%20SERIES%20TEXT%2F6261044'
// -H 'accept: text/plain' --header "Range: bytes=20000-40000"
dao.getClob(clobId, office, c -> {
if (c == null) {
ctx.status(HttpServletResponse.SC_NOT_FOUND).json(new CdaError("Unable to find "
+ "clob based on given parameters"));
} else {
try (InputStream is = c.getAsciiStream()) {
RangeRequestUtil.seekableStream(ctx, is, TEXT_PLAIN, c.length());
}
}
});

ctx.header(Header.ACCEPT_RANGES, "bytes");

StreamConsumer consumer = (is, isPosition, mediaType, totalLength) -> {
requestResultSize.update(totalLength);
RangeRequestUtil.seekableStream(ctx, is, isPosition, mediaType, totalLength);
};

dao.getClob(clobId, office, consumer);

} else {
Optional<Clob> optAc = dao.getByUniqueName(clobId, office);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@
import com.codahale.metrics.Timer;
import cwms.cda.api.errors.CdaError;
import cwms.cda.data.dao.ForecastInstanceDao;
import cwms.cda.data.dao.StreamConsumer;
import cwms.cda.helpers.DateUtils;
import io.javalin.core.util.Header;
import io.javalin.http.Context;
import io.javalin.http.Handler;
import io.javalin.plugin.openapi.annotations.OpenApi;
import io.javalin.plugin.openapi.annotations.OpenApiContent;
import io.javalin.plugin.openapi.annotations.OpenApiParam;
import io.javalin.plugin.openapi.annotations.OpenApiResponse;
import org.jetbrains.annotations.NotNull;

import javax.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.time.Instant;

import static com.codahale.metrics.MetricRegistry.name;
Expand Down Expand Up @@ -92,7 +94,7 @@ private Timer.Context markAndTime(String subject) {
},
tags = {ForecastSpecController.TAG}
)
public void handle(Context ctx) {
public void handle(@NotNull Context ctx) {
String specId = requiredParam(ctx, NAME);
String office = requiredParam(ctx, OFFICE);
String designator = ctx.queryParamAsClass(DESIGNATOR, String.class).allowNullable().get();
Expand All @@ -102,18 +104,17 @@ public void handle(Context ctx) {
Instant issueInstant = DateUtils.parseUserDate(issueDate, "UTC").toInstant();
try (Timer.Context ignored = markAndTime(GET_ALL)) {
ForecastInstanceDao dao = new ForecastInstanceDao(getDslContext(ctx));
dao.getFileBlob(office, specId, designator, forecastInstant, issueInstant, (blob, mediaType) -> {
if (blob == null) {
StreamConsumer streamConsumer = (is, isPosition, mediaType, totalLength) -> {
if (is == null) {
ctx.status(HttpServletResponse.SC_NOT_FOUND).json(new CdaError("Unable to find "
+ "blob based on given parameters"));
} else {
long size = blob.length();
requestResultSize.update(size);
try (InputStream is = blob.getBinaryStream()) {
RangeRequestUtil.seekableStream(ctx, is, mediaType, size);
}
requestResultSize.update(totalLength);
ctx.header(Header.ACCEPT_RANGES, "bytes");
RangeRequestUtil.seekableStream(ctx, is, isPosition, mediaType, totalLength);
}
});
};
dao.getFileBlob(office, specId, designator, forecastInstant, issueInstant, streamConsumer);
}
}
}
128 changes: 128 additions & 0 deletions cwms-data-api/src/main/java/cwms/cda/api/RangeParser.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
package cwms.cda.api;

import org.jetbrains.annotations.NotNull;

import java.util.*;
import java.util.regex.*;

/**
* Utility class for parsing HTTP Range headers.
* These typically look like: bytes=100-1234
* or: bytes=100- this is common to resume a download
* or: bytes=0- equivalent to a regular request for the whole file
* but by returning 206 we show that we support range requests
* Note that multiple ranges can be requested at once such
* as: bytes=500-600,700-999 Server responds identifies separator and then puts separator between chunks
* bytes=0-0,-1 also legal its just the first and the last byte
* or: bytes=500-600,601-999 legal but what is the point?
* or: bytes=500-700,601-999 legal, notice they overlap.
*
*
*/
public class RangeParser {

private static final Pattern RANGE_PATTERN = Pattern.compile("(\\d*)-(\\d*)");

/**
* Return a list of two element long[] containing byte ranges parsed from the HTTP Range header.
* If the end of a range is not specified ( e.g. bytes=100- ) then a -1 is returned in the second position
* If the range only includes a negative byte (e.g bytes=-50) then -1 is returned as the start of the range
* and -1*end is returned as the end of the range. bytes=-50 will result in [-1,50]
*
* @param header the HTTP Range header this should start with "bytes=" if it is null or empty an empty list is returned
* @return a list of long[2] holding the ranges
*/
public static List<long[]> parse(String header) {
if (header == null || header.isEmpty() ) {
return Collections.emptyList();
} else if ( !header.startsWith("bytes=")){
throw new IllegalArgumentException("Invalid Range header: " + header);
}

String rangePart = header.substring(6);
List<long[]> retval = parseRanges(rangePart);
if( retval.isEmpty() ){
throw new IllegalArgumentException("Invalid Range header: " + header);
}
return retval;
}

public static long[] parseFirstRange(String header) {
if(header != null) {
List<long[]> ranges = RangeParser.parse(header);
if (!ranges.isEmpty()) {
return ranges.get(0);
}
}
return null;
}

public static @NotNull List<long[]> parseRanges(String rangePart) {
if( rangePart == null || rangePart.isEmpty() ){
throw new IllegalArgumentException("Invalid range specified: " + rangePart);
}
String[] parts = rangePart.split(",");
List<long[]> ranges = new ArrayList<>();

for (String part : parts) {
Matcher m = RANGE_PATTERN.matcher(part.trim());
if (m.matches()) {
String start = m.group(1);
String end = m.group(2);

long s = start.isEmpty() ? -1 : Long.parseLong(start);
long e = end.isEmpty() ? -1 : Long.parseLong(end);

ranges.add(new long[]{s, e});
}
}
return ranges;
}

/**
* The parse() method in this class can return -1 for unspecified values or when suffix ranges are supplied.
* This method interprets the negative values in regard to the totalSize and returns inclusive indices of the
* requested range.
* @param inputs the array of start and end byte positions
* @param totalBytes the total number of bytes in the file
* @return a long array with the start and end byte positions, these are inclusive. [0,0] means return the first byte
*/
public static long[] interpret(long[] inputs, long totalBytes){
if(inputs == null){
throw new IllegalArgumentException("null range array provided");
} else if( inputs.length != 2 ){
throw new IllegalArgumentException("Invalid number of inputs: " + Arrays.toString(inputs));
}

long start = inputs[0];
long end = inputs[1];

if(start == -1L){
// its a suffix request.
start = totalBytes - end;
end = totalBytes - 1;
} else {
if (start < 0 ) {
throw new IllegalArgumentException("Invalid range specified: " + Arrays.toString(inputs));
}

if(end == -1L){
end = totalBytes - 1;
}

if(end < start){
throw new IllegalArgumentException("Invalid range specified: " + Arrays.toString(inputs));
}

if(start > totalBytes - 1){
throw new IllegalArgumentException("Can't satisfy range request: " + Arrays.toString(inputs) + " Range starts beyond end of file.");
}

end = Math.min(end, totalBytes - 1);
}

return new long[]{start, end};
}


}
Loading
Loading