/**
* Copyright (C) 2013-2015 all@code-story.net
*
* 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
*/
package net.codestory.http.payload;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.Objects.requireNonNull;
import static net.codestory.http.constants.Encodings.GZIP;
import static net.codestory.http.constants.Headers.ACCEPT_ENCODING;
import static net.codestory.http.constants.Headers.CACHE_CONTROL;
import static net.codestory.http.constants.Headers.CONNECTION;
import static net.codestory.http.constants.Headers.CONTENT_ENCODING;
import static net.codestory.http.constants.Headers.CONTENT_TYPE;
import static net.codestory.http.constants.Headers.ETAG;
import static net.codestory.http.constants.Headers.IF_MODIFIED_SINCE;
import static net.codestory.http.constants.Headers.IF_NONE_MATCH;
import static net.codestory.http.constants.Headers.LAST_MODIFIED;
import static net.codestory.http.constants.HttpStatus.CONTINUE;
import static net.codestory.http.constants.HttpStatus.INTERNAL_SERVER_ERROR;
import static net.codestory.http.constants.HttpStatus.NOT_FOUND;
import static net.codestory.http.constants.HttpStatus.NOT_MODIFIED;
import static net.codestory.http.constants.HttpStatus.NO_CONTENT;
import static net.codestory.http.constants.HttpStatus.OK;
import static net.codestory.http.constants.Methods.HEAD;
import static net.codestory.http.io.Strings.stripQuotes;
import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintStream;
import java.net.URL;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import java.util.NoSuchElementException;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Stream;
import java.util.zip.GZIPOutputStream;
import net.codestory.http.Request;
import net.codestory.http.Response;
import net.codestory.http.compilers.CacheEntry;
import net.codestory.http.compilers.CompilerException;
import net.codestory.http.compilers.CompilerFacade;
import net.codestory.http.compilers.SourceFile;
import net.codestory.http.convert.TypeConvert;
import net.codestory.http.errors.ErrorPage;
import net.codestory.http.errors.ErrorPayload;
import net.codestory.http.errors.HttpException;
import net.codestory.http.io.InputStreams;
import net.codestory.http.io.Resources;
import net.codestory.http.logs.Logs;
import net.codestory.http.misc.Dates;
import net.codestory.http.misc.Env;
import net.codestory.http.misc.Md5;
import net.codestory.http.templating.Model;
import net.codestory.http.templating.ModelAndView;
import net.codestory.http.templating.Site;
import net.codestory.http.types.ContentTypes;
public class PayloadWriter {
protected final Request request;
protected final Response response;
protected final Env env;
protected final Site site;
protected final Resources resources;
protected final CompilerFacade compilers;
public PayloadWriter(Request request, Response response, Env env, Site site, Resources resources, CompilerFacade compilers) {
this.request = request;
this.response = response;
this.env = env;
this.site = site;
this.resources = resources;
this.compilers = compilers;
}
public void writeAndClose(Payload payload) throws IOException {
if (isAsync(payload)) {
writeAndCloseAsync(payload);
} else {
writeAndCloseSync(payload);
}
}
protected void writeAndCloseSync(Payload payload) throws IOException {
if (payload.isError() && (payload.rawContent() == null)) {
payload = errorPage(payload.code(), null)
.withHeaders(payload.headers())
.withCookies(payload.cookies());
}
write(payload);
close();
}
protected CompletableFuture<Void> writeAndCloseAsync(Payload payload) {
CompletableFuture<?> future = (CompletableFuture<?>) payload.rawContent();
return future.thenAccept(content -> {
try {
writeAndCloseSync(new Payload(content));
} catch (Exception e) {
writeErrorPage(e);
}
}).exceptionally(e -> {
writeErrorPage(e);
return null;
});
}
protected boolean isAsync(Payload payload) {
return payload.rawContent() instanceof CompletableFuture<?>;
}
public void writeErrorPage(Throwable e) {
try {
if (e instanceof CompilerException) {
Logs.compilerError(e);
} else if (!(e instanceof HttpException) && !(e instanceof NoSuchElementException)) {
Logs.unexpectedError(e);
}
Payload errorPage = errorPage(e);
if (!env.prodMode()) {
errorPage = errorPage.withHeader("reason", e.getMessage());
}
writeAndCloseSync(errorPage);
} catch (IOException error) {
Logs.unableToServeErrorPage(error);
}
}
protected void close() {
try {
response.close();
} catch (IOException e) {
// Ignore
}
}
protected void write(Payload payload) throws IOException {
response.setHeaders(payload.headers());
response.setCookies(payload.cookies());
long lastModified = getLastModified(payload);
if (lastModified > 0) { // 0 is invalid
String previousLastModified = stripQuotes(request.header(IF_MODIFIED_SINCE));
if ((previousLastModified != null) && (lastModified < Dates.parseRfc1123(previousLastModified))) {
response.setStatus(NOT_MODIFIED);
return;
}
response.setHeader(LAST_MODIFIED, Dates.toRfc1123(lastModified));
}
int code = payload.code();
String contentType = payload.rawContentType();
Object content = payload.rawContent();
if (content == null) {
response.setStatus(code);
response.setContentLength(0);
return;
}
String uri = request.uri();
String contentTypeHeader = (contentType != null) ? contentType : getContentType(content, uri);
response.setHeader(CONTENT_TYPE, contentTypeHeader);
response.setStatus(code);
if (HEAD.equals(request.method()) || (code == NO_CONTENT) || (code == NOT_MODIFIED) || ((code >= CONTINUE) && (code < OK))) {
return;
}
if (isStream(content)) {
streamPayload(uri, payload);
} else {
writeBytes(uri, payload);
}
}
protected void writeBytes(String uri, Payload payload) throws IOException {
DataSupplier lazyData = DataSupplier.cache(() -> getData(payload.rawContent(), uri));
String etag = payload.headers().get(ETAG);
if (etag == null) {
etag = etag(lazyData.get());
}
String previousEtag = stripQuotes(request.header(IF_NONE_MATCH));
if (etag.equals(previousEtag)) {
response.setStatus(NOT_MODIFIED);
return;
}
response.setHeader(ETAG, etag);
byte[] data = lazyData.get();
write(data);
}
protected void writeStreamingHeaders() throws IOException {
response.setHeader(CACHE_CONTROL, "no-cache");
response.setHeader(CONNECTION, "keep-alive");
}
protected void streamPayload(String uri, Payload payload) throws IOException {
writeStreamingHeaders();
if (payload.rawContent() instanceof Stream<?>) {
writeEventStream(payload);
} else if (payload.rawContent() instanceof BufferedReader) {
writeBufferedReader(payload);
} else if (payload.rawContent() instanceof InputStream) {
writeInputStream(payload);
} else if (payload.rawContent() instanceof StreamingOutput) {
writeStreamingOutput(payload);
}
}
protected void writeEventStream(Payload payload) throws IOException {
PrintStream printStream = new PrintStream(response.outputStream());
try (Stream<?> stream = (Stream<?>) payload.rawContent()) {
stream.map(item -> {
String jsonOrPlainString = (item instanceof String) ? (String) item : TypeConvert.toJson(item);
printStream
.append("data: ")
.append(jsonOrPlainString.replaceAll("[\n]", "\ndata: "))
.append("\n\n");
return printStream.checkError();
}).filter(ioExceptionHasOccurred -> ioExceptionHasOccurred)
.findFirst();
}
}
protected void writeBufferedReader(Payload payload) throws IOException {
BufferedReader lines = (BufferedReader) payload.rawContent();
writeStreamingOutput(output -> {
try (PrintStream printStream = new PrintStream(output, true)) {
String line;
while (null != (line = lines.readLine())) {
printStream.println(line);
}
}
});
}
protected void writeInputStream(Payload payload) throws IOException {
InputStream stream = (InputStream) payload.rawContent();
writeStreamingOutput(output -> InputStreams.copy(stream, output));
}
protected void writeStreamingOutput(Payload payload) throws IOException {
StreamingOutput stream = (StreamingOutput) payload.rawContent();
writeStreamingOutput(stream);
}
protected void writeStreamingOutput(StreamingOutput stream) throws IOException {
try {
if (shouldGzip()) {
response.setHeader(CONTENT_ENCODING, GZIP);
GZIPOutputStream gzip = new GZIPOutputStream(response.outputStream());
stream.write(gzip);
gzip.finish();
} else {
stream.write(response.outputStream());
}
} catch (IOException e) {
if (!shouldIgnoreError(e)) {
throw e;
}
}
}
protected void write(byte[] data) throws IOException {
try {
if (shouldGzip()) {
response.setHeader(CONTENT_ENCODING, GZIP);
GZIPOutputStream gzip = new GZIPOutputStream(response.outputStream());
gzip.write(data);
gzip.finish();
} else {
response.setContentLength(data.length);
response.outputStream().write(data);
}
} catch (IOException e) {
if (!shouldIgnoreError(e)) {
throw e;
}
}
}
protected boolean shouldGzip() {
return env.gzip() && env.prodMode() && request.header(ACCEPT_ENCODING, "").contains(GZIP);
}
protected boolean shouldIgnoreError(IOException e) {
Throwable cause = e.getCause();
if (cause != null) {
String message = cause.getMessage();
if ((message != null) && (message.contains("Connection reset by peer") || message.contains("Broken pipe"))) {
return true;
}
}
return false;
}
protected String etag(byte[] data) {
return Md5.of(data);
}
protected boolean isStream(Object content) {
return (content instanceof Stream<?>) || (content instanceof BufferedReader) || (content instanceof InputStream) || (content instanceof StreamingOutput);
}
protected String getContentType(Object content, String uri) {
if (content instanceof File) {
File file = (File) content;
return ContentTypes.get(file.getName());
}
if (content instanceof Path) {
Path path = (Path) content;
return ContentTypes.get(path.toString());
}
if (content instanceof SourceFile) {
SourceFile sourceFile = (SourceFile) content;
return ContentTypes.get(sourceFile.getPath().toString());
}
if (content instanceof URL) {
return ContentTypes.get(((URL) content).getFile());
}
if ((content instanceof String) || (content instanceof CacheEntry)) {
return "text/html;charset=UTF-8";
}
if (content instanceof BufferedReader) {
return "text/plain;charset=UTF-8";
}
if ((content instanceof byte[]) || (content instanceof InputStream) || (content instanceof StreamingOutput)) {
return "application/octet-stream";
}
if (content instanceof Stream<?>) {
return "text/event-stream;charset=UTF-8";
}
if (content instanceof ModelAndView) {
Path path = resources.findExistingPath(((ModelAndView) content).view());
requireNonNull(path, "View not found for " + uri);
return ContentTypes.get(path.toString());
}
if (content instanceof Model) {
Path path = resources.findExistingPath(uri);
requireNonNull(path, "View not found for " + uri);
return ContentTypes.get(path.toString());
}
return "application/json;charset=UTF-8";
}
protected byte[] getData(Object content, String uri) throws IOException {
if (content == null) {
return null;
}
if (content instanceof File) {
return forPath(((File) content).toPath());
}
if (content instanceof Path) {
return forPath((Path) content);
}
if (content instanceof SourceFile) {
return forSourceFile((SourceFile) content);
}
if (content instanceof URL) {
return forURL((URL) content);
}
if (content instanceof byte[]) {
return (byte[]) content;
}
if (content instanceof String) {
return forString((String) content);
}
if (content instanceof CacheEntry) {
return ((CacheEntry) content).toBytes();
}
if (content instanceof ModelAndView) {
return forModelAndView((ModelAndView) content);
}
if (content instanceof Model) {
return forModelAndView(ModelAndView.of(uri, (Model) content));
}
return toJson(content);
}
protected byte[] toJson(Object content) {
return TypeConvert.toByteArray(content);
}
protected long getLastModified(Payload payload) throws IOException {
String lastModified = payload.headers().get(LAST_MODIFIED);
if (lastModified != null) {
return Dates.parseRfc1123(lastModified);
}
Object content = payload.rawContent();
if (content instanceof File) {
return resources.lastModified(((File) content).toPath());
}
if (content instanceof Path) {
return resources.lastModified((Path) content);
}
return -1;
}
protected byte[] forString(String value) {
return value.getBytes(UTF_8);
}
protected byte[] forModelAndView(ModelAndView modelAndView) {
Map<String, Object> keyValues = new HashMap<>();
keyValues.putAll(modelAndView.model().keyValues());
keyValues.put("cookies", request.cookies().keyValues());
keyValues.put("env", env);
keyValues.put("site", site);
keyValues.put("request", request);
keyValues.put("response", response);
String body = compilers.renderView(modelAndView.view(), keyValues);
return forString(body);
}
protected byte[] forURL(URL url) throws IOException {
try (InputStream stream = url.openStream()) {
return InputStreams.readBytes(stream);
} catch (IOException e) {
throw new IllegalStateException("Unable to read url:" + url, e);
}
}
protected byte[] forPath(Path path) throws IOException {
if (supportsTemplating(path)) {
return forTemplatePath(path);
}
return resources.readBytes(path);
}
protected byte[] forSourceFile(SourceFile sourceFile) throws IOException {
Path path = sourceFile.getPath();
if (supportsTemplating(path)) {
return forTemplatePath(path);
}
return compilers.compile(sourceFile).toBytes();
}
protected boolean supportsTemplating(Path path) {
return compilers.supportsTemplating(path);
}
protected byte[] forTemplatePath(Path path) {
return forModelAndView(ModelAndView.of(Resources.toUnixString(path)));
}
protected Payload errorPage(Throwable e) {
int code = INTERNAL_SERVER_ERROR;
if (e instanceof HttpException) {
code = ((HttpException) e).code();
} else if (e instanceof NoSuchElementException) {
code = NOT_FOUND;
}
return errorPage(code, e);
}
protected Payload errorPage(int errorCode, Throwable e) {
String defaultAcceptedType = "text/html";
String acceptedTyped = request.header("Accept", defaultAcceptedType);
for (String acceptedType : acceptedTyped.split("[,]")) {
if ("text/html".equals(acceptedType)) {
return errorPageHtml(errorCode, e);
}
}
return errorAsJson(errorCode, e);
}
protected Payload errorPageHtml(int errorCode, Throwable e) {
return new ErrorPage(errorCode, e).payload();
}
protected Payload errorAsJson(int errorCode, Throwable e) {
return new Payload("application/json;charset=UTF-8", new ErrorPayload(e), errorCode);
}
}