Merge pull request #422 from apache/WW-5080-plain-result

[WW-5080] Defines a new result type plain to use directly with Java code
diff --git a/core/src/main/java/org/apache/struts2/result/PlainResult.java b/core/src/main/java/org/apache/struts2/result/PlainResult.java
new file mode 100644
index 0000000..b398b93
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/PlainResult.java
@@ -0,0 +1,104 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result;
+
+import com.opensymphony.xwork2.ActionInvocation;
+import com.opensymphony.xwork2.Result;
+import org.apache.logging.log4j.LogManager;
+import org.apache.logging.log4j.Logger;
+import org.apache.logging.log4j.message.ParameterizedMessage;
+import org.apache.struts2.StrutsException;
+import org.apache.struts2.result.plain.HttpHeader;
+import org.apache.struts2.result.plain.ResponseBuilder;
+
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * This result can only be used in code, as a result of action's method, eg.:
+ * <p>
+ * public PlainResult execute() {
+ * return response -> response.write("");
+ * }
+ * <p>
+ * Please notice the result type of the method is a PlainResult not a String.
+ */
+public interface PlainResult extends Result {
+
+    Logger LOG = LogManager.getLogger(PlainResult.class);
+
+    @Override
+    default void execute(ActionInvocation invocation) throws Exception {
+        LOG.debug("Executing plain result");
+        ResponseBuilder builder = new ResponseBuilder();
+        write(builder);
+
+        HttpServletResponse response = invocation.getInvocationContext().getServletResponse();
+
+        if (response.isCommitted()) {
+            if (ignoreCommitted()) {
+                LOG.warn("Http response already committed, ignoring & skipping!");
+                return;
+            } else {
+                throw new StrutsException("Http response already committed, cannot modify it!");
+            }
+        }
+
+        for (HttpHeader<String> header : builder.getStringHeaders()) {
+            LOG.debug(new ParameterizedMessage("A string header: {} = {}", header.getName(), header.getValue()));
+            response.addHeader(header.getName(), header.getValue());
+        }
+        for (HttpHeader<Long> header : builder.getDateHeaders()) {
+            LOG.debug(new ParameterizedMessage("A date header: {} = {}", header.getName(), header.getValue()));
+            response.addDateHeader(header.getName(), header.getValue());
+        }
+        for (HttpHeader<Integer> header : builder.getIntHeaders()) {
+            LOG.debug(new ParameterizedMessage("An int header: {} = {}", header.getName(), header.getValue()));
+            response.addIntHeader(header.getName(), header.getValue());
+        }
+
+        for (Cookie cookie : builder.getCookies()) {
+            LOG.debug(new ParameterizedMessage("A cookie: {} = {}", cookie.getName(), cookie.getValue()));
+            response.addCookie(cookie);
+        }
+
+        response.getWriter().write(builder.getBody());
+        response.flushBuffer();
+    }
+
+    /**
+     * Implement this method in action using lambdas
+     *
+     * @param response a response builder used to build a Http response
+     */
+    void write(ResponseBuilder response);
+
+    /**
+     * Controls if result should ignore already committed Http response
+     * If set to true only a warning will be issued and the rest of the result
+     * will be skipped
+     *
+     * @return boolean false by default which means an exception will be thrown
+     */
+    default boolean ignoreCommitted() {
+        return false;
+    }
+
+}
+
diff --git a/core/src/main/java/org/apache/struts2/result/plain/BodyWriter.java b/core/src/main/java/org/apache/struts2/result/plain/BodyWriter.java
new file mode 100644
index 0000000..ff22769
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/BodyWriter.java
@@ -0,0 +1,41 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+import java.io.StringWriter;
+
+class BodyWriter {
+
+    private final StringWriter body = new StringWriter();
+
+    public BodyWriter write(String out) {
+        body.write(out);
+        return this;
+    }
+
+    public BodyWriter writeLine(String out) {
+        body.write(out);
+        body.write("\n");
+        return this;
+    }
+
+    public String getBody() {
+        return body.toString();
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/DateHttpHeader.java b/core/src/main/java/org/apache/struts2/result/plain/DateHttpHeader.java
new file mode 100644
index 0000000..906860b
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/DateHttpHeader.java
@@ -0,0 +1,38 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+class DateHttpHeader implements HttpHeader<Long> {
+
+    private final String name;
+    private final Long value;
+
+    public DateHttpHeader(String name, Long value) {
+        this.name = name;
+        this.value = value;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Long getValue() {
+        return value;
+    }
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/HttpCookies.java b/core/src/main/java/org/apache/struts2/result/plain/HttpCookies.java
new file mode 100644
index 0000000..22c105b
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/HttpCookies.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+import javax.servlet.http.Cookie;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+class HttpCookies {
+
+    private final List<Cookie> cookies = new ArrayList<>();
+
+    public HttpCookies add(String name, String value) {
+        cookies.add(new Cookie(name, value));
+        return this;
+    }
+
+    public List<Cookie> getCookies() {
+        return Collections.unmodifiableList(cookies);
+    }
+
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/HttpHeader.java b/core/src/main/java/org/apache/struts2/result/plain/HttpHeader.java
new file mode 100644
index 0000000..90de858
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/HttpHeader.java
@@ -0,0 +1,27 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+public interface HttpHeader<T> {
+
+    String getName();
+
+    T getValue();
+
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/HttpHeaders.java b/core/src/main/java/org/apache/struts2/result/plain/HttpHeaders.java
new file mode 100644
index 0000000..31715c6
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/HttpHeaders.java
@@ -0,0 +1,58 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+
+class HttpHeaders {
+
+    private final List<HttpHeader<String>> stringHeaders = new ArrayList<>();
+    private final List<HttpHeader<Long>> dateHeaders = new ArrayList<>();
+    private final List<HttpHeader<Integer>> intHeaders = new ArrayList<>();
+
+    public HttpHeaders add(String name, String value) {
+        stringHeaders.add(new StringHttpHeader(name, value));
+        return this;
+    }
+
+    public HttpHeaders add(String name, Long value) {
+        dateHeaders.add(new DateHttpHeader(name, value));
+        return this;
+    }
+
+    public HttpHeaders add(String name, Integer value) {
+        intHeaders.add(new IntHttpHeader(name, value));
+        return this;
+    }
+
+    public List<HttpHeader<String>> getStringHeaders() {
+        return Collections.unmodifiableList(stringHeaders);
+    }
+
+    public List<HttpHeader<Long>> getDateHeaders() {
+        return Collections.unmodifiableList(dateHeaders);
+    }
+
+    public List<HttpHeader<Integer>> getIntHeaders() {
+        return Collections.unmodifiableList(intHeaders);
+    }
+
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/IntHttpHeader.java b/core/src/main/java/org/apache/struts2/result/plain/IntHttpHeader.java
new file mode 100644
index 0000000..c190617
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/IntHttpHeader.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+class IntHttpHeader implements HttpHeader<Integer> {
+
+    private final String name;
+    private final Integer value;
+
+    public IntHttpHeader(String name, Integer value) {
+        this.name = name;
+        this.value = value;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public Integer getValue() {
+        return value;
+    }
+
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/ResponseBuilder.java b/core/src/main/java/org/apache/struts2/result/plain/ResponseBuilder.java
new file mode 100644
index 0000000..5e1ae20
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/ResponseBuilder.java
@@ -0,0 +1,111 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+import javax.servlet.http.Cookie;
+
+public class ResponseBuilder {
+
+    public static final String CONTENT_TYPE = "Content-Type";
+
+    public static final String TEXT_PLAIN = "text/plain";
+    public static final String TEXT_HTML = "text/html";
+    public static final String APPLICATION_JSON = "application/json";
+
+    private final BodyWriter body;
+    private final HttpHeaders headers;
+    private final HttpCookies cookies;
+
+    public ResponseBuilder() {
+        this.body = new BodyWriter();
+        this.headers = new HttpHeaders().add(CONTENT_TYPE, TEXT_PLAIN + "; charset=UTF-8");
+        this.cookies = new HttpCookies();
+    }
+
+    public ResponseBuilder write(String out) {
+        body.write(out);
+        return this;
+    }
+
+    public ResponseBuilder writeLine(String out) {
+        body.writeLine(out);
+        return this;
+    }
+
+    public ResponseBuilder withHeader(String name, String value) {
+        headers.add(name, value);
+        return this;
+    }
+
+    public ResponseBuilder withHeader(String name, Long value) {
+        headers.add(name, value);
+        return this;
+    }
+
+    public ResponseBuilder withHeader(String name, Integer value) {
+        headers.add(name, value);
+        return this;
+    }
+
+    public ResponseBuilder withContentTypeTextPlain() {
+        headers.add(CONTENT_TYPE, TEXT_PLAIN + "; charset=UTF-8");
+        return this;
+    }
+
+    public ResponseBuilder withContentTypeTextHtml() {
+        headers.add(CONTENT_TYPE, TEXT_HTML + "; charset=UTF-8");
+        return this;
+    }
+
+    public ResponseBuilder withContentTypeJson() {
+        headers.add(CONTENT_TYPE, APPLICATION_JSON);
+        return this;
+    }
+
+    public ResponseBuilder withContentType(String contentType) {
+        headers.add(CONTENT_TYPE, contentType);
+        return this;
+    }
+
+    public ResponseBuilder withCookie(String name, String value) {
+        cookies.add(name, value);
+        return this;
+    }
+
+    public Iterable<HttpHeader<String>> getStringHeaders() {
+        return headers.getStringHeaders();
+    }
+
+    public Iterable<HttpHeader<Long>> getDateHeaders() {
+        return headers.getDateHeaders();
+    }
+
+    public Iterable<HttpHeader<Integer>> getIntHeaders() {
+        return headers.getIntHeaders();
+    }
+
+    public Iterable<Cookie> getCookies() {
+        return cookies.getCookies();
+    }
+
+    public String getBody() {
+        return body.getBody();
+    }
+
+}
diff --git a/core/src/main/java/org/apache/struts2/result/plain/StringHttpHeader.java b/core/src/main/java/org/apache/struts2/result/plain/StringHttpHeader.java
new file mode 100644
index 0000000..e4e3e03
--- /dev/null
+++ b/core/src/main/java/org/apache/struts2/result/plain/StringHttpHeader.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result.plain;
+
+class StringHttpHeader implements HttpHeader<String> {
+
+    private final String name;
+    private final String value;
+
+    public StringHttpHeader(String name, String value) {
+        this.name = name;
+        this.value = value;
+    }
+
+    public String getName() {
+        return name;
+    }
+
+    public String getValue() {
+        return value;
+    }
+
+}
diff --git a/core/src/test/java/org/apache/struts2/result/PlainResultTest.java b/core/src/test/java/org/apache/struts2/result/PlainResultTest.java
new file mode 100644
index 0000000..4cd193c
--- /dev/null
+++ b/core/src/test/java/org/apache/struts2/result/PlainResultTest.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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.
+ */
+package org.apache.struts2.result;
+
+import com.opensymphony.xwork2.ActionContext;
+import com.opensymphony.xwork2.mock.MockActionInvocation;
+import org.apache.struts2.StrutsException;
+import org.apache.struts2.StrutsInternalTestCase;
+import org.apache.struts2.result.plain.ResponseBuilder;
+import org.springframework.mock.web.MockHttpServletResponse;
+
+public class PlainResultTest extends StrutsInternalTestCase {
+
+    private MockHttpServletResponse response;
+    private MockActionInvocation invocation;
+
+    public void testWritePlainText() throws Exception {
+        PlainResult result = (PlainResult) response ->
+            response.write("test").withContentTypeTextPlain();
+
+        result.execute(invocation);
+
+        assertEquals("test", response.getContentAsString());
+        assertEquals("text/plain; charset=UTF-8", response.getContentType());
+    }
+
+    public void testWritePlainHtml() throws Exception {
+        PlainResult result = (PlainResult) response ->
+            response.write("<b>test</b>").withContentTypeTextHtml();
+
+        result.execute(invocation);
+
+        assertEquals("<b>test</b>", response.getContentAsString());
+        assertEquals("text/html; charset=UTF-8", response.getContentType());
+    }
+
+    public void testWriteJson() throws Exception {
+        PlainResult result = (PlainResult) response ->
+            response.write("{ 'value': 'test' }").withContentTypeJson();
+
+        result.execute(invocation);
+
+        assertEquals("{ 'value': 'test' }", response.getContentAsString());
+        assertEquals("application/json", response.getContentType());
+    }
+
+    public void testWriteContentTypeCsvWithCookie() throws Exception {
+        PlainResult result = (PlainResult) response ->
+            response.writeLine("name;value")
+                .withContentType("text/csv")
+                .withCookie("X-Test", "test")
+                .writeLine("line;1")
+                .write("line;2");
+
+        result.execute(invocation);
+
+        assertEquals("name;value\nline;1\nline;2", response.getContentAsString());
+        assertEquals("text/csv", response.getContentType());
+    }
+
+    public void testHeaders() throws Exception {
+        PlainResult result = (PlainResult) response ->
+            response.withHeader("X-String", "test")
+                .withHeader("X-Date", 0L)
+                .withHeader("X-Number", 100)
+                .write("");
+
+        result.execute(invocation);
+
+        assertEquals("", response.getContentAsString());
+        assertEquals("text/plain; charset=UTF-8", response.getContentType());
+        assertEquals("test", response.getHeader("X-String"));
+        assertEquals("Thu, 01 Jan 1970 00:00:00 GMT", response.getHeader("X-Date"));
+        assertEquals("100", response.getHeader("X-NUmber"));
+    }
+
+    public void testExceptionOnCommitted() throws Exception {
+        response.setCommitted(true);
+
+        PlainResult result = (PlainResult) response ->
+            response.write("");
+
+        try {
+            result.execute(invocation);
+            fail("Exception was expected!");
+        } catch (StrutsException e) {
+            assertEquals("Http response already committed, cannot modify it!", e.getMessage());
+        }
+    }
+
+    public void testNoExceptionOnCommitted() throws Exception {
+        response.setCommitted(true);
+
+        PlainResult result = new PlainResult() {
+            @Override
+            public void write(ResponseBuilder response) {
+                response.write("");
+            }
+
+            @Override
+            public boolean ignoreCommitted() {
+                return true;
+            }
+        };
+
+        try {
+            result.execute(invocation);
+            assertTrue(true);
+        } catch (StrutsException e) {
+            fail(e.getMessage());
+        }
+    }
+
+    public void setUp() throws Exception {
+        super.setUp();
+        invocation = new MockActionInvocation();
+        response = new MockHttpServletResponse();
+        invocation.setInvocationContext(ActionContext.getContext());
+
+        ActionContext.getContext().withServletResponse(response).withActionInvocation(invocation);
+    }
+}
\ No newline at end of file