/* 4 * Copyright 2012 Jason Miller * * 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 jj.http.server; import java.io.IOException; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.Date; import java.util.Map.Entry; import java.util.concurrent.atomic.AtomicReference; import javax.inject.Inject; import javax.inject.Singleton; import io.netty.handler.codec.http.*; import jj.Version; import jj.event.Publisher; import io.netty.buffer.ByteBuf; import io.netty.buffer.Unpooled; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelFutureListener; import io.netty.channel.ChannelHandlerContext; import io.netty.util.AsciiString; import io.netty.handler.stream.ChunkedNioFile; /** * @author jason * */ @Singleton class HttpServerResponseImpl implements HttpServerResponse { private final HttpServerRequestImpl request; private final ChannelHandlerContext ctx; private final Publisher publisher; @Inject HttpServerResponseImpl( final Version version, final HttpServerRequestImpl request, final ChannelHandlerContext ctx, final Publisher publisher ) { this.request = request; this.ctx = ctx; this.publisher = publisher; header(HttpHeaderNames.SERVER, String.format( "%s/%s (%s)", version.name(), version.version(), version.branchName() )); } protected final DefaultHttpResponse response = new DefaultHttpResponse(HttpVersion.HTTP_1_1, HttpResponseStatus.OK); protected AtomicReference<ByteBuf> content = new AtomicReference<>(); private volatile boolean isCommitted = false; protected final Charset charset = StandardCharsets.UTF_8; protected void assertNotCommitted() { assert !isCommitted : "response has already been committed. modification is not permitted"; } protected void markCommitted() { this.isCommitted = true; } @Override public HttpResponseStatus status() { return response.status(); } /** * sets the status of the outgoing response * @param status * @return */ @Override public HttpServerResponse status(final HttpResponseStatus status) { assertNotCommitted(); response.setStatus(status); return this; } @Override public HttpServerResponse header(final AsciiString name, final CharSequence value) { assertNotCommitted(); response.headers().add(name, value); return this; } @Override public HttpServerResponse headerIfNotSet(final AsciiString name, final CharSequence value) { assertNotCommitted(); if (!containsHeader(name)) { header(name, value); } return this; } @Override public HttpServerResponse headerIfNotSet(final AsciiString name, final long value) { assertNotCommitted(); if (!containsHeader(name)) { header(name, value); } return this; } @Override public boolean containsHeader(AsciiString name) { return response.headers().contains(name); } @Override public HttpServerResponse header(final AsciiString name, final Date date) { assertNotCommitted(); response.headers().add(name, date); return this; } @Override public HttpServerResponse header(final AsciiString name, final long value) { assertNotCommitted(); response.headers().add(name, value); return this; } /** * @return */ @Override public Iterable<Entry<String, String>> allHeaders() { // TODO make unmodifiable if committed return response.headers(); } @Override public CharSequence header(AsciiString name) { return response.headers().get(name); } @Override public HttpVersion version() { return response.protocolVersion(); } protected ByteBuf content() { if (content.get() == null) { content.compareAndSet(null, Unpooled.buffer(0)); } return content.get(); } @Override public HttpServerResponse content(final byte[] bytes) { assertNotCommitted(); content().writeBytes(bytes); return this; } @Override public HttpServerResponse content(final ByteBuf buffer) { assertNotCommitted(); content().writeBytes(buffer); return this; } /** * @return */ @Override public Charset charset() { return charset; } /** * @return */ @Override public String contentsString() { return content().toString(charset); } @Override public void sendError(final HttpResponseStatus status) { assertNotCommitted(); byte[] body = status.reasonPhrase().getBytes(StandardCharsets.US_ASCII); status(status) .header(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_STORE) .header(HttpHeaderNames.CONTENT_LENGTH, body.length) .header(HttpHeaderNames.CONTENT_TYPE, "text/plain; UTF-8") .content(body) .end(); } @Override public void sendNotFound() { assertNotCommitted(); sendError(HttpResponseStatus.NOT_FOUND); } /** * Sends a 304 Not Modified for the given resource with no cache headers. Ends * the response. * @param resource * @return */ @Override public HttpServerResponse sendNotModified(final ServableResource resource) { return sendNotModified(resource, false); } /** * Sends a 304 Not Modified for the given resource, caching the response for * one year if {@code cache} is true. Ends the response. * @param resource * @param cache * @return */ @Override public HttpServerResponse sendNotModified(final ServableResource resource, boolean cache) { assertNotCommitted(); if (cache) { header(HttpHeaderNames.CACHE_CONTROL, MAX_AGE_ONE_YEAR); } return status(HttpResponseStatus.NOT_MODIFIED) .header(HttpHeaderNames.ETAG, resource.sha1()) .end(); } /** * Sends a 307 Temporary Redirect to the given resource, using the fully qualified * asset URL and disallowing the redirect to be cached. Ends the response. * @param resource * @return */ @Override public HttpServerResponse sendTemporaryRedirect(final ServableResource resource) { assertNotCommitted(); return status(HttpResponseStatus.TEMPORARY_REDIRECT) .header(HttpHeaderNames.LOCATION, makeAbsoluteURL(resource)) .header(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_STORE) .end(); } @Override public HttpServerResponse sendUncachableResource(ServableResource resource) throws IOException { assertNotCommitted(); header(HttpHeaderNames.CACHE_CONTROL, HttpHeaderValues.NO_CACHE); if (resource instanceof TransferableResource) { return sendResource((TransferableResource)resource); } else if (resource instanceof LoadedResource) { return sendResource((LoadedResource)resource); } throw new AssertionError("trying to send a resource I don't understand"); } @Override public HttpServerResponse sendCachableResource(ServableResource resource) throws IOException { assertNotCommitted(); header(HttpHeaderNames.CACHE_CONTROL, MAX_AGE_ONE_YEAR); if (resource instanceof TransferableResource) { return sendResource((TransferableResource)resource); } else if (resource instanceof LoadedResource) { return sendResource((LoadedResource)resource); } throw new AssertionError("trying to send a resource I don't understand"); } /** * Responds with the given resource and bytes as a 200 OK, not setting any * validation headers and turning caching off if no cache control headers have * previously been set on the response. this is the appropriate responding * method for dynamically generated responses (not including simple statically * compiled dynamic resources, like less->css) */ protected HttpServerResponse sendResource(final LoadedResource resource) { return header(HttpHeaderNames.ETAG, resource.sha1()) .header(HttpHeaderNames.CONTENT_LENGTH, resource.bytes().readableBytes()) .header(HttpHeaderNames.CONTENT_TYPE, resource.contentType()) .content(resource.bytes()) .end(); } /** * Transfers a resource to the connected client using the operating system * zero-copy facilities. * * @param resource * @return */ protected HttpServerResponse sendResource(TransferableResource resource) throws IOException { header(HttpHeaderNames.CONTENT_TYPE, resource.contentType()) .header(HttpHeaderNames.CONTENT_LENGTH, resource.size()) .header(HttpHeaderNames.DATE, new Date()); if (resource.sha1() != null) { header(HttpHeaderNames.ETAG, resource.sha1()); } return doSendTransferableResource(resource); } private ChannelFuture maybeClose(final ChannelFuture f) { if (!HttpUtil.isKeepAlive(request.request())) { f.addListener(ChannelFutureListener.CLOSE); } publisher.publish(new RequestResponded(request, this)); return f; } @Override public HttpServerResponse end() { assertNotCommitted(); header(HttpHeaderNames.DATE, new Date()); ctx.write(response); ctx.write(content()); maybeClose(ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)); markCommitted(); return this; } protected String makeAbsoluteURL(final ServableResource resource) { return new StringBuilder("http") .append(request.secure() ? "s" : "") .append("://") .append(request.host()) .append(resource.uri()) .toString(); } @Override public HttpServerResponse error(Throwable t) { publisher.publish(new RequestErrored(t)); sendError(HttpResponseStatus.INTERNAL_SERVER_ERROR); return this; } /** * actually writes the stuff to the channel * * how to test this? */ protected HttpServerResponse doSendTransferableResource(TransferableResource resource) throws IOException { ctx.write(response); ctx.write(new ChunkedNioFile(resource.fileChannel())); maybeClose(ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)); // configuration can decide if we're doing zero-copy or chunking? // can i even make zero-copy work again? haha //maybeClose(ctx.writeAndFlush(new DefaultFileRegion(resource.fileChannel(), 0, resource.size()))); markCommitted(); return this; } /** * @return {@code true} if the response has no body, {@code false} otherwise */ public boolean hasNoBody() { return content().readableBytes() == 0; } }