From d09b254f009b2dc8208cc5a2b4f4ccdf85a21d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Wed, 27 May 2020 09:43:59 +0200 Subject: [PATCH] Add line limits to content endpoint --- .../scm/api/v2/resources/ContentResource.java | 31 +++++-- .../resources/LineFilteredOutputStream.java | 72 ++++++++++++++++ .../api/v2/resources/ContentResourceTest.java | 28 +++++-- .../LineFilteredOutputStreamTest.java | 82 +++++++++++++++++++ 4 files changed, 197 insertions(+), 16 deletions(-) create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/LineFilteredOutputStream.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/LineFilteredOutputStreamTest.java 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"); + } +}