blob: 253a5160e4b5eb9afe634c1aec7b42ac10f92446 [file] [log] [blame]
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
library shelf.request;
import 'dart:async';
import 'dart:convert';
import 'package:http_parser/http_parser.dart';
import 'hijack_exception.dart';
import 'message.dart';
import 'util.dart';
/// A callback provided by a Shelf handler that's passed to [Request.hijack].
typedef void HijackCallback(
Stream<List<int>> stream, StreamSink<List<int>> sink);
/// A callback provided by a Shelf adapter that's used by [Request.hijack] to
/// provide a [HijackCallback] with a socket.
typedef void OnHijackCallback(HijackCallback callback);
/// Represents an HTTP request to be processed by a Shelf application.
class Request extends Message {
/// The remainder of the [requestedUri] path and query designating the virtual
/// "location" of the request's target within the handler.
///
/// [url] may be an empty, if [requestedUri]targets the handler
/// root and does not have a trailing slash.
///
/// [url] is never null. If it is not empty, it will start with `/`.
///
/// [scriptName] and [url] combine to create a valid path that should
/// correspond to the [requestedUri] path.
final Uri url;
/// The HTTP request method, such as "GET" or "POST".
final String method;
/// The initial portion of the [requestedUri] path that corresponds to the
/// handler.
///
/// [scriptName] allows a handler to know its virtual "location".
///
/// If the handler corresponds to the "root" of a server, it will be an
/// empty string, otherwise it will start with a `/`
///
/// [scriptName] and [url] combine to create a valid path that should
/// correspond to the [requestedUri] path.
final String scriptName;
/// The HTTP protocol version used in the request, either "1.0" or "1.1".
final String protocolVersion;
/// The original [Uri] for the request.
final Uri requestedUri;
/// The callback wrapper for hijacking this request.
///
/// This will be `null` if this request can't be hijacked.
final _OnHijack _onHijack;
/// Whether this request can be hijacked.
///
/// This will be `false` either if the adapter doesn't support hijacking, or
/// if the request has already been hijacked.
bool get canHijack => _onHijack != null && !_onHijack.called;
/// If this is non-`null` and the requested resource hasn't been modified
/// since this date and time, the server should return a 304 Not Modified
/// response.
///
/// This is parsed from the If-Modified-Since header in [headers]. If
/// [headers] doesn't have an If-Modified-Since header, this will be `null`.
DateTime get ifModifiedSince {
if (_ifModifiedSinceCache != null) return _ifModifiedSinceCache;
if (!headers.containsKey('if-modified-since')) return null;
_ifModifiedSinceCache = parseHttpDate(headers['if-modified-since']);
return _ifModifiedSinceCache;
}
DateTime _ifModifiedSinceCache;
/// Creates a new [Request].
///
/// If [url] and [scriptName] are omitted, they are inferred from
/// [requestedUri].
///
/// Setting one of [url] or [scriptName] and not the other will throw an
/// [ArgumentError].
///
/// [body] is the request body. It may be either a [String], a
/// [Stream<List<int>>], or `null` to indicate no body.
/// If it's a [String], [encoding] is used to encode it to a
/// [Stream<List<int>>]. The default encoding is UTF-8.
///
/// If [encoding] is passed, the "encoding" field of the Content-Type header
/// in [headers] will be set appropriately. If there is no existing
/// Content-Type header, it will be set to "application/octet-stream".
///
/// The default value for [protocolVersion] is '1.1'.
///
/// ## `onHijack`
///
/// [onHijack] allows handlers to take control of the underlying socket for
/// the request. It should be passed by adapters that can provide access to
/// the bidirectional socket underlying the HTTP connection stream.
///
/// The [onHijack] callback will only be called once per request. It will be
/// passed another callback which takes a byte stream and a byte sink.
/// [onHijack] must pass the stream and sink for the connection stream to this
/// callback, although it may do so asynchronously. Both parameters may be the
/// same object. If the user closes the sink, the adapter should ensure that
/// the stream is closed as well.
///
/// If a request is hijacked, the adapter should expect to receive a
/// [HijackException] from the handler. This is a special exception used to
/// indicate that hijacking has occurred. The adapter should avoid either
/// sending a response or notifying the user of an error if a
/// [HijackException] is caught.
///
/// An adapter can check whether a request was hijacked using [canHijack],
/// which will be `false` for a hijacked request. The adapter may throw an
/// error if a [HijackException] is received for a non-hijacked request, or if
/// no [HijackException] is received for a hijacked request.
///
/// See also [hijack].
// TODO(kevmoo) finish documenting the rest of the arguments.
Request(String method, Uri requestedUri, {String protocolVersion,
Map<String, String> headers, Uri url, String scriptName, body,
Encoding encoding, Map<String, Object> context,
OnHijackCallback onHijack})
: this._(method, requestedUri,
protocolVersion: protocolVersion,
headers: headers,
url: url,
scriptName: scriptName,
body: body,
encoding: encoding,
context: context,
onHijack: onHijack == null ? null : new _OnHijack(onHijack));
/// This constructor has the same signature as [new Request] except that
/// accepts [onHijack] as [_OnHijack].
///
/// Any [Request] created by calling [change] will pass [_onHijack] from the
/// source [Request] to ensure that [hijack] can only be called once, even
/// from a changed [Request].
Request._(this.method, Uri requestedUri, {String protocolVersion,
Map<String, String> headers, Uri url, String scriptName, body,
Encoding encoding, Map<String, Object> context, _OnHijack onHijack})
: this.requestedUri = requestedUri,
this.protocolVersion = protocolVersion == null
? '1.1'
: protocolVersion,
this.url = _computeUrl(requestedUri, url, scriptName),
this.scriptName = _computeScriptName(requestedUri, url, scriptName),
this._onHijack = onHijack,
super(body, encoding: encoding, headers: headers, context: context) {
if (method.isEmpty) throw new ArgumentError('method cannot be empty.');
if (!requestedUri.isAbsolute) {
throw new ArgumentError('requstedUri must be an absolute URI.');
}
// TODO(kevmoo) if defined, check that scriptName is a fully-encoded, valid
// path component
if (this.scriptName.isNotEmpty && !this.scriptName.startsWith('/')) {
throw new ArgumentError('scriptName must be empty or start with "/".');
}
if (this.scriptName == '/') {
throw new ArgumentError(
'scriptName can never be "/". It should be empty instead.');
}
if (this.scriptName.endsWith('/')) {
throw new ArgumentError('scriptName must not end with "/".');
}
if (this.url.path.isNotEmpty && !this.url.path.startsWith('/')) {
throw new ArgumentError('url must be empty or start with "/".');
}
if (this.scriptName.isEmpty && this.url.path.isEmpty) {
throw new ArgumentError('scriptName and url cannot both be empty.');
}
}
/// Creates a new [Request] by copying existing values and applying specified
/// changes.
///
/// New key-value pairs in [context] and [headers] will be added to the copied
/// [Request].
///
/// If [context] or [headers] includes a key that already exists, the
/// key-value pair will replace the corresponding entry in the copied
/// [Request].
///
/// All other context and header values from the [Request] will be included
/// in the copied [Request] unchanged.
///
/// If [scriptName] is provided and [url] is not, [scriptName] must be a
/// prefix of [this.url]. [url] will default to [this.url] with this prefix
/// removed. Useful for routing middleware that sends requests to an inner
/// [Handler].
Request change({Map<String, String> headers, Map<String, Object> context,
String scriptName, Uri url}) {
headers = updateMap(this.headers, headers);
context = updateMap(this.context, context);
if (scriptName != null && url == null) {
var path = this.url.path;
if (path.startsWith(scriptName)) {
path = path.substring(scriptName.length);
url = new Uri(path: path, query: this.url.query);
} else {
throw new ArgumentError('If scriptName is provided without url, it must'
' be a prefix of the existing url path.');
}
}
if (url == null) url = this.url;
if (scriptName == null) scriptName = this.scriptName;
return new Request._(this.method, this.requestedUri,
protocolVersion: this.protocolVersion,
headers: headers,
url: url,
scriptName: scriptName,
body: this.read(),
context: context,
onHijack: _onHijack);
}
/// Takes control of the underlying request socket.
///
/// Synchronously, this throws a [HijackException] that indicates to the
/// adapter that it shouldn't emit a response itself. Asynchronously,
/// [callback] is called with a [Stream<List<int>>] and
/// [StreamSink<List<int>>], respectively, that provide access to the
/// underlying request socket.
///
/// If the sink is closed, the stream will be closed as well. The stream and
/// sink may be the same object, as in the case of a `dart:io` `Socket`
/// object.
///
/// This may only be called when using a Shelf adapter that supports
/// hijacking, such as the `dart:io` adapter. In addition, a given request may
/// only be hijacked once. [canHijack] can be used to detect whether this
/// request can be hijacked.
void hijack(HijackCallback callback) {
if (_onHijack == null) {
throw new StateError("This request can't be hijacked.");
}
_onHijack.run(callback);
throw const HijackException();
}
}
/// A class containing a callback for [Request.hijack] that also tracks whether
/// the callback has been called.
class _OnHijack {
/// The callback.
final OnHijackCallback _callback;
/// Whether [this] has been called.
bool called = false;
_OnHijack(this._callback);
/// Calls [this].
///
/// Throws a [StateError] if [this] has already been called.
void run(HijackCallback callback) {
if (called) throw new StateError("This request has already been hijacked.");
called = true;
newFuture(() => _callback(callback));
}
}
/// Computes `url` from the provided [Request] constructor arguments.
///
/// If [url] and [scriptName] are `null`, infer value from [requestedUrl],
/// otherwise return [url].
///
/// If [url] is provided, but [scriptName] is omitted, throws an
/// [ArgumentError].
Uri _computeUrl(Uri requestedUri, Uri url, String scriptName) {
if (url == null && scriptName == null) {
return new Uri(path: requestedUri.path, query: requestedUri.query);
}
if (url != null && scriptName != null) {
if (url.scheme.isNotEmpty) throw new ArgumentError('url must be relative.');
return url;
}
throw new ArgumentError(
'url and scriptName must both be null or both be set.');
}
/// Computes `scriptName` from the provided [Request] constructor arguments.
///
/// If [url] and [scriptName] are `null` it returns an empty string, otherwise
/// [scriptName] is returned.
///
/// If [script] is provided, but [url] is omitted, throws an
/// [ArgumentError].
String _computeScriptName(Uri requstedUri, Uri url, String scriptName) {
if (url == null && scriptName == null) {
return '';
}
if (url != null && scriptName != null) {
return scriptName;
}
throw new ArgumentError(
'url and scriptName must both be null or both be set.');
}