diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java
index 8cd62a4bc6..47e0de16d1 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ContentResource.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import com.github.sdorra.spotter.ContentType;
@@ -44,12 +44,14 @@ import javax.ws.rs.GET;
import javax.ws.rs.HEAD;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
+import javax.ws.rs.QueryParam;
import javax.ws.rs.WebApplicationException;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.Response.Status;
import javax.ws.rs.core.StreamingOutput;
import java.io.IOException;
import java.io.InputStream;
+import java.io.OutputStream;
import java.util.Arrays;
public class ContentResource {
@@ -68,11 +70,12 @@ public class ContentResource {
* Returns the content of a file for the given revision in the repository. The content type depends on the file
* content and can be discovered calling HEAD on the same URL. If a programming languge could be
* recognized, this will be given in the header Language.
- *
- * @param namespace the namespace of the repository
+ * @param namespace the namespace of the repository
* @param name the name of the repository
* @param revision the revision
* @param path The path of the file
+ * @param start
+ * @param end
*/
@GET
@Path("{revision}/{path: .*}")
@@ -94,8 +97,14 @@ public class ContentResource {
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
- public Response get(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) {
- StreamingOutput stream = createStreamingOutput(namespace, name, revision, path);
+ public Response get(
+ @PathParam("namespace") String namespace,
+ @PathParam("name") String name,
+ @PathParam("revision") String revision,
+ @PathParam("path") String path,
+ @QueryParam("start") Integer start,
+ @QueryParam("end") Integer end) {
+ StreamingOutput stream = createStreamingOutput(namespace, name, revision, path, start, end);
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
Response.ResponseBuilder responseBuilder = Response.ok(stream);
return createContentHeader(namespace, name, revision, path, repositoryService, responseBuilder);
@@ -105,11 +114,17 @@ public class ContentResource {
}
}
- private StreamingOutput createStreamingOutput(@PathParam("namespace") String namespace, @PathParam("name") String name, @PathParam("revision") String revision, @PathParam("path") String path) {
+ private StreamingOutput createStreamingOutput(String namespace, String name, String revision, String path, Integer start, Integer end) {
return os -> {
+ OutputStream sourceOut;
+ if (start != null || end != null) {
+ sourceOut = new LineFilteredOutputStream(os, start, end);
+ } else {
+ sourceOut = os;
+ }
try (RepositoryService repositoryService = serviceFactory.create(new NamespaceAndName(namespace, name))) {
- repositoryService.getCatCommand().setRevision(revision).retriveContent(os, path);
- os.close();
+ repositoryService.getCatCommand().setRevision(revision).retriveContent(sourceOut, path);
+ sourceOut.close();
} catch (NotFoundException e) {
LOG.debug(e.getMessage());
throw new WebApplicationException(Status.NOT_FOUND);
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java
new file mode 100644
index 0000000000..5de813cabd
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java
@@ -0,0 +1,72 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import java.io.IOException;
+import java.io.OutputStream;
+
+class LineFilteredOutputStream extends OutputStream {
+ private final OutputStream target;
+ private final int start;
+ private final Integer end;
+
+ private boolean inLineBreak;
+ private int currentLine = 0;
+
+ LineFilteredOutputStream(OutputStream target, Integer start, Integer end) {
+ this.target = target;
+ this.start = start == null ? 0 : start;
+ this.end = end == null ? Integer.MAX_VALUE : end;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ switch (b) {
+ case '\n':
+ case '\r':
+ if (!inLineBreak) {
+ inLineBreak = true;
+ ++currentLine;
+ }
+ break;
+ default:
+ if (inLineBreak && currentLine > start && currentLine <= end) {
+ target.write('\n');
+ }
+ inLineBreak = false;
+ if (currentLine >= start && currentLine < end) {
+ target.write(b);
+ }
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ if (inLineBreak && currentLine >= start && currentLine < end) {
+ target.write('\n');
+ }
+ target.close();
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java
index a1f12bba2b..c009751195 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ContentResourceTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import com.google.common.io.Resources;
@@ -89,7 +89,7 @@ public class ContentResourceTest {
public void shouldReadSimpleFile() throws Exception {
mockContent("file", "Hello".getBytes());
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file", null, null);
assertEquals(200, response.getStatus());
ByteArrayOutputStream baos = readOutputStream(response);
@@ -97,15 +97,27 @@ public class ContentResourceTest {
assertEquals("Hello", baos.toString());
}
+ @Test
+ public void shouldLimitOutputByLines() throws Exception {
+ mockContent("file", "line 1\nline 2\nline 3\nline 4".getBytes());
+
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "file", 1, 3);
+ assertEquals(200, response.getStatus());
+
+ ByteArrayOutputStream baos = readOutputStream(response);
+
+ assertEquals("line 2\nline 3\n", baos.toString());
+ }
+
@Test
public void shouldHandleMissingFile() {
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "doesNotExist");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "doesNotExist", null, null);
assertEquals(404, response.getStatus());
}
@Test
public void shouldHandleMissingRepository() {
- Response response = contentResource.get("no", "repo", REV, "anything");
+ Response response = contentResource.get("no", "repo", REV, "anything", null, null);
assertEquals(404, response.getStatus());
}
@@ -113,7 +125,7 @@ public class ContentResourceTest {
public void shouldRecognizeTikaSourceCode() throws Exception {
mockContentFromResource("SomeGoCode.go");
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "SomeGoCode.go", null, null);
assertEquals(200, response.getStatus());
assertEquals("golang", response.getHeaderString("X-Programming-Language"));
@@ -124,7 +136,7 @@ public class ContentResourceTest {
public void shouldRecognizeSpecialSourceCode() throws Exception {
mockContentFromResource("Dockerfile");
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "Dockerfile", null, null);
assertEquals(200, response.getStatus());
assertEquals("dockerfile", response.getHeaderString("X-Programming-Language"));
@@ -135,7 +147,7 @@ public class ContentResourceTest {
public void shouldHandleRandomByteFile() throws Exception {
mockContentFromResource("JustBytes");
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "JustBytes");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "JustBytes", null, null);
assertEquals(200, response.getStatus());
assertFalse(response.getHeaders().containsKey("Language"));
@@ -158,7 +170,7 @@ public class ContentResourceTest {
public void shouldHandleEmptyFile() throws Exception {
mockContent("empty", new byte[]{});
- Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "empty");
+ Response response = contentResource.get(NAMESPACE, REPO_NAME, REV, "empty", null, null);
assertEquals(200, response.getStatus());
assertFalse(response.getHeaders().containsKey("Language"));
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java
new file mode 100644
index 0000000000..ecaff9c88a
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java
@@ -0,0 +1,82 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class LineFilteredOutputStreamTest {
+
+ static final String INPUT_LF = "line 1\nline 2\nline 3\nline 4";
+ static final String INPUT_CR_LF = "line 1\r\nline 2\r\nline 3\r\nline 4";
+ static final String INPUT_CR = "line 1\rline 2\rline 3\rline 4";
+
+ ByteArrayOutputStream target = new ByteArrayOutputStream();
+
+ @ParameterizedTest
+ @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR})
+ void shouldNotFilterIfStartAndEndAreNotSet(String input) throws IOException {
+ try (LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, null, null)) {
+ filtered.write(input.getBytes());
+ }
+
+ assertThat(target.toString()).isEqualTo(INPUT_LF);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR})
+ void shouldNotFilterIfStartAndEndAreSetToLimits(String input) throws IOException {
+ try (LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 0, 4)) {
+ filtered.write(input.getBytes());
+ }
+
+ assertThat(target.toString()).isEqualTo(INPUT_LF);
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR})
+ void shouldRemoveFirstLinesIfStartIsSetGreaterThat1(String input) throws IOException {
+ LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, 2, null);
+
+ filtered.write(input.getBytes());
+
+ assertThat(target.toString()).isEqualTo("line 3\nline 4");
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {INPUT_LF, INPUT_CR_LF, INPUT_CR})
+ void shouldOmitLastLinesIfEndIsSetLessThatLength(String input) throws IOException {
+ LineFilteredOutputStream filtered = new LineFilteredOutputStream(target, null, 2);
+
+ filtered.write(input.getBytes());
+
+ assertThat(target.toString()).isEqualTo("line 1\nline 2\n");
+ }
+}