blob: 182881fb298c391948bc285eb6128bcd317bfb61 [file] [log] [blame]
/*
* 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.openwhisk.core.controller
import java.util.Base64
import scala.concurrent.Future
import scala.util.{Failure, Success, Try}
import akka.http.scaladsl.model.HttpEntity.Empty
import akka.http.scaladsl.server.Directives
import akka.http.scaladsl.model.HttpMethod
import akka.http.scaladsl.model.HttpHeader
import akka.http.scaladsl.model.MediaType
import akka.http.scaladsl.model.MediaTypes
import akka.http.scaladsl.model.MediaTypes._
import akka.http.scaladsl.model.StatusCodes._
import akka.http.scaladsl.model.StatusCode
import akka.http.scaladsl.model.headers.RawHeader
import akka.http.scaladsl.model.headers._
import akka.http.scaladsl.model.Uri.Query
import akka.http.scaladsl.model.HttpEntity
import akka.http.scaladsl.server.Route
import akka.http.scaladsl.model.headers.`Content-Type`
import akka.http.scaladsl.model.headers.`Timeout-Access`
import akka.http.scaladsl.model.ContentType
import akka.http.scaladsl.model.ContentTypes
import akka.http.scaladsl.model.FormData
import akka.http.scaladsl.model.HttpMethods.{OPTIONS}
import akka.http.scaladsl.model.HttpCharsets
import akka.http.scaladsl.model.HttpResponse
import spray.json._
import spray.json.DefaultJsonProtocol._
import WhiskWebActionsApi.MediaExtension
import RestApiCommons.{jsonPrettyResponsePrinter => jsonPrettyPrinter}
import org.apache.openwhisk.common.TransactionId
import org.apache.openwhisk.core.controller.actions.PostActionActivation
import org.apache.openwhisk.core.database._
import org.apache.openwhisk.core.entitlement.{Collection, Privilege, Resource}
import org.apache.openwhisk.core.entity._
import org.apache.openwhisk.core.entity.types._
import org.apache.openwhisk.core.loadBalancer.LoadBalancerException
import org.apache.openwhisk.http.ErrorResponse.terminate
import org.apache.openwhisk.http.Messages
import org.apache.openwhisk.http.LenientSprayJsonSupport._
import org.apache.openwhisk.spi.SpiLoader
import org.apache.openwhisk.utils.JsHelpers._
import org.apache.openwhisk.core.entity.Exec
protected[controller] sealed class WebApiDirectives(prefix: String = "__ow_") {
// enforce the presence of an extension (e.g., .http) in the URI path
val enforceExtension = false
// the field name that represents the status code for an http action response
val statusCode = "statusCode"
// parameters that are added to an action input to pass HTTP request context values
val method: String = fields("method")
val headers: String = fields("headers")
val path: String = fields("path")
val namespace: String = fields("user")
val query: String = fields("query")
val body: String = fields("body")
lazy val reservedProperties: Set[String] = Set(method, headers, path, namespace, query, body)
protected final def fields(f: String) = s"$prefix$f"
}
private case class Context(propertyMap: WebApiDirectives,
method: HttpMethod,
headers: Seq[HttpHeader],
path: String,
query: Query,
body: Option[JsValue] = None) {
val queryAsMap = query.toMap
// returns true iff the attached query and body parameters contain a property
// that conflicts with the given reserved parameters
def overrides(reservedParams: Set[String]): Boolean = {
val queryParams = queryAsMap.keySet
val bodyParams = body
.map {
case JsObject(fields) => fields.keySet
case _ => Set.empty
}
.getOrElse(Set.empty)
(queryParams ++ bodyParams).forall(key => !reservedParams.contains(key))
}
// attach the body to the Context
def withBody(b: Option[JsValue]) = Context(propertyMap, method, headers, path, query, b)
def metadata(user: Option[Identity]): Map[String, JsValue] = {
Map(
propertyMap.method -> method.value.toLowerCase.toJson,
propertyMap.headers -> headers
.collect {
case h if h.name != `Timeout-Access`.name => h.lowercaseName -> h.value
}
.toMap
.toJson,
propertyMap.path -> path.toJson) ++
user.map(u => propertyMap.namespace -> u.namespace.name.asString.toJson)
}
def toActionArgument(user: Option[Identity], boxQueryAndBody: Boolean): Map[String, JsValue] = {
val queryParams = if (boxQueryAndBody) {
Map(propertyMap.query -> JsString(query.toString))
} else {
queryAsMap.map(kv => kv._1 -> JsString(kv._2))
}
// if the body is a json object, merge with query parameters
// otherwise, this is an opaque body that will be nested under
// __ow_body in the parameters sent to the action as an argument
val bodyParams: Map[String, JsValue] = body match {
case Some(JsObject(fields)) if !boxQueryAndBody => fields
case Some(v) => Map(propertyMap.body -> v)
case None if !boxQueryAndBody => Map.empty
case _ => Map(propertyMap.body -> JsString.empty)
}
// precedence order is: query params -> body (last wins)
metadata(user) ++ queryParams ++ bodyParams
}
}
protected[core] object WhiskWebActionsApi extends Directives {
private val mediaTranscoders = {
// extensions are expected to contain only [a-z]
Seq(
MediaExtension(".http", resultAsHttp _),
MediaExtension(".json", resultAsJson _),
MediaExtension(".html", resultAsHtml _),
MediaExtension(".svg", resultAsSvg _),
MediaExtension(".text", resultAsText _))
}
private val defaultMediaTranscoder: MediaExtension = mediaTranscoders.find(_.extension == ".http").get
val allowedExtensions: Set[String] = mediaTranscoders.map(_.extension).toSet
/**
* Splits string into a base name plus optional extension.
* If name ends with ".xxxx" which matches a known extension, accept it as the extension.
* Otherwise, the extension is ".http" by definition unless enforcing the presence of an extension.
*/
def mediaTranscoderForName(name: String, enforceExtension: Boolean): (String, Option[MediaExtension]) = {
mediaTranscoders
.find(mt => name.endsWith(mt.extension))
.map { mt =>
val base = name.dropRight(mt.extensionLength)
(base, Some(mt))
}
.getOrElse {
(name, if (enforceExtension) None else Some(defaultMediaTranscoder))
}
}
/**
* Supported extensions, their default projection and transcoder to complete a request.
*
* @param extension the supported media types for action response
* @param transcoder the HTTP decoder and terminator for the extension
*/
protected case class MediaExtension(extension: String,
transcoder: (JsValue, TransactionId, WebApiDirectives) => Route) {
val extensionLength = extension.length
}
private def resultAsHtml(result: JsValue, transid: TransactionId, rp: WebApiDirectives) = {
val htmlResult = result match {
case JsObject(fields) => fields.get("body").orElse(fields.get("html")).getOrElse(JsNull)
case _ => result
}
htmlResult match {
case JsString(html) => complete(HttpEntity(ContentTypes.`text/html(UTF-8)`, html))
case _ => terminate(BadRequest, Messages.invalidMedia(`text/html`))(transid, jsonPrettyPrinter)
}
}
private def resultAsSvg(result: JsValue, transid: TransactionId, rp: WebApiDirectives) = {
val svgResult = result match {
case JsObject(fields) => fields.get("body").orElse(fields.get("svg")).getOrElse(JsNull)
case _ => result
}
svgResult match {
case JsString(svg) => complete(HttpEntity(`image/svg+xml`, svg.getBytes))
case _ => terminate(BadRequest, Messages.invalidMedia(`image/svg+xml`))(transid, jsonPrettyPrinter)
}
}
private def resultAsText(result: JsValue, transid: TransactionId, rp: WebApiDirectives) = {
val txtResult = result match {
case JsObject(fields) => fields.get("body").orElse(fields.get("text"))
case _ => Some(result)
}
txtResult match {
case Some(r: JsObject) => complete(OK, r.prettyPrint)
case Some(r: JsArray) => complete(OK, r.prettyPrint)
case Some(JsString(s)) => complete(OK, s)
case Some(JsBoolean(b)) => complete(OK, b.toString)
case Some(JsNumber(n)) => complete(OK, n.toString)
case Some(JsNull) => complete(OK, JsNull.toString)
case _ => terminate(NotFound, Messages.propertyNotFound)(transid, jsonPrettyPrinter)
}
}
private def resultAsJson(result: JsValue, transid: TransactionId, rp: WebApiDirectives) = {
result match {
case r: JsObject => complete(OK, r)
case r: JsArray => complete(OK, r)
case _ => terminate(BadRequest, Messages.invalidMedia(`application/json`))(transid, jsonPrettyPrinter)
}
}
private def resultAsHttp(result: JsValue, transid: TransactionId, rp: WebApiDirectives) = {
Try {
val JsObject(fields) = result
val headers = fields.get("headers").map {
case JsObject(hs) =>
hs.flatMap {
case (k, v) => headersFromJson(k, v)
}.toList
case _ => throw new Throwable("Invalid header")
} getOrElse List.empty
val body = fields.get("body")
val code = fields.get(rp.statusCode).map {
case JsNumber(c) =>
// the following throws an exception if the code is not a whole number or a valid code
StatusCode.int2StatusCode(c.toIntExact)
case JsString(c) =>
// parse the string to an Int (not a BigInt) matching JsNumber case match above
// c.toInt could throw an exception if the string isn't an integer
StatusCode.int2StatusCode(c.toInt)
case _ => throw new Throwable("Illegal status code")
}
body.collect {
case JsString(str) if str.nonEmpty => interpretHttpResponse(code.getOrElse(OK), headers, str, transid)
case JsString(str) /* str.isEmpty */ => respondWithEmptyEntity(code.getOrElse(NoContent), headers)
case js if js != JsNull => interpretHttpResponseAsJson(code.getOrElse(OK), headers, js, transid)
} getOrElse respondWithEmptyEntity(code.getOrElse(NoContent), headers)
} getOrElse {
// either the result was not a JsObject or there was an exception validating the
// response as an http result
terminate(BadRequest, Messages.invalidMedia(`message/http`))(transid, jsonPrettyPrinter)
}
}
private def respondWithEmptyEntity(code: StatusCode, headers: List[RawHeader]) = {
respondWithHeaders(removeContentTypeHeader(headers)) {
// note that if header defined a content-type, it will be ignored
// since the type must be compatible with the data response
complete(HttpResponse(code, entity = HttpEntity.Empty))
}
}
private def headersFromJson(k: String, v: JsValue): Seq[RawHeader] = v match {
case JsString(v) => Seq(RawHeader(k, v))
case JsBoolean(v) => Seq(RawHeader(k, v.toString))
case JsNumber(v) => Seq(RawHeader(k, v.toString))
case JsArray(v) => v.flatMap(inner => headersFromJson(k, inner))
case _ => throw new Throwable("Invalid header")
}
/**
* Finds the content-type in the header list and ensures that it is a valid format. If it is not
* valid, construct a failure with appropriate message.
* If the content-type header is missing, then return the supplied defaultType
*/
private def findContentTypeInHeader(headers: List[RawHeader],
transid: TransactionId,
defaultType: MediaType): Try[MediaType] = {
headers.find(_.lowercaseName == `Content-Type`.lowercaseName) match {
case Some(header) =>
MediaType.parse(header.value) match {
case Right(mediaType: MediaType) => Success(mediaType)
case _ => Failure(RejectRequest(BadRequest, Messages.httpUnknownContentType)(transid))
}
case None => Success(defaultType)
}
}
def isJsonFamily(mt: MediaType): Boolean = {
mt == `application/json` || mt.value.endsWith("+json")
}
private def interpretHttpResponseAsJson(code: StatusCode,
headers: List[RawHeader],
js: JsValue,
transid: TransactionId) = {
findContentTypeInHeader(headers, transid, `application/json`) match {
// use the default akka-http response marshaler for standard application/json
case Success(mediaType) if mediaType == `application/json` =>
respondWithHeaders(removeContentTypeHeader(headers)) {
complete(code, js)
}
// for all other json-family content-type, explicitly marshal the response;
// the order of the case statement matters; isJsonFamily returns true for application/json
case Success(mediaType) if isJsonFamily(mediaType) =>
respondWithHeaders(removeContentTypeHeader(headers)) {
complete(
code,
HttpEntity(
ContentType(
MediaType.customWithFixedCharset(mediaType.mainType, mediaType.subType, HttpCharsets.`UTF-8`)),
js.prettyPrint))
}
case _ =>
terminate(BadRequest, Messages.httpContentTypeError)(transid, jsonPrettyPrinter)
}
}
private def interpretHttpResponse(code: StatusCode, headers: List[RawHeader], str: String, transid: TransactionId) = {
findContentTypeInHeader(headers, transid, `text/html`).flatMap { mediaType =>
val ct = ContentType(mediaType, () => HttpCharsets.`UTF-8`)
ct match {
// TODO: remove this extract check for base64 on json response
// this is here for legacy reasons to not brake old webactions returning base64 json that have not migrated yet
case nonbinary: ContentType.NonBinary if (isJsonFamily(mediaType) && Exec.isBinaryCode(str)) =>
Try(Base64.getDecoder().decode(str)).map(HttpEntity(ct, _))
case nonbinary: ContentType.NonBinary => Success(HttpEntity(nonbinary, str))
// because of the default charset provided to the content type constructor
// the remaining content types to match against are binary at this point
case _ /* ContentType.Binary */ => Try(Base64.getDecoder().decode(str)).map(HttpEntity(ct, _))
}
} match {
case Success(entity) =>
respondWithHeaders(removeContentTypeHeader(headers)) {
complete(code, entity)
}
case Failure(RejectRequest(code, message)) =>
terminate(code, message)(transid, jsonPrettyPrinter)
case _ =>
terminate(BadRequest, Messages.httpContentTypeError)(transid, jsonPrettyPrinter)
}
}
private def removeContentTypeHeader(headers: List[RawHeader]) =
headers.filter(_.lowercaseName != `Content-Type`.lowercaseName)
}
trait WhiskWebActionsApi
extends Directives
with ValidateRequestSize
with PostActionActivation
with CustomHeaders
with CorsSettings.WebActions {
services: WhiskServices =>
/** API path invocation path for posting activations directly through the host. */
protected val webInvokePathSegments: Seq[String]
/** Mapping of HTTP request fields to action parameter names. */
protected val webApiDirectives: WebApiDirectives
/** Store for identities. */
protected val authStore: AuthStore
/** Configured authentication provider. */
protected val authenticationProvider = SpiLoader.get[AuthenticationDirectiveProvider]
/** The collection type for this trait. */
protected val collection = Collection(Collection.ACTIONS)
/** The prefix for web invokes e.g., /web. */
private lazy val webRoutePrefix = {
pathPrefix(webInvokePathSegments.map(_segmentStringToPathMatcher(_)).reduceLeft(_ / _))
}
/** Allowed verbs. */
private lazy val allowedOperations = get | delete | post | put | head | options | patch
private lazy val validNameSegment = pathPrefix(EntityName.REGEX.r)
private lazy val packagePrefix = pathPrefix("default".r | EntityName.REGEX.r)
private val defaultCorsBaseResponse =
List(allowOrigin, allowMethods)
private val defaultCorsWithAllowHeader = {
defaultCorsBaseResponse :+ allowHeaders
}
private def defaultCorsResponse(headers: Seq[HttpHeader]): List[HttpHeader] = {
headers.find(_.name == `Access-Control-Request-Headers`.name).map { h =>
defaultCorsBaseResponse :+ `Access-Control-Allow-Headers`(h.value)
} getOrElse defaultCorsWithAllowHeader
}
private def contentTypeFromEntity(entity: HttpEntity) = entity.contentType match {
case ct if ct == ContentTypes.NoContentType => None
case ct => Some(RawHeader(`Content-Type`.lowercaseName, ct.toString))
}
/** Extracts the HTTP method, headers, query params and unmatched (remaining) path. */
private val requestMethodParamsAndPath = {
extract { ctx =>
val method = ctx.request.method
val query = ctx.request.uri.query()
val path = ctx.unmatchedPath.toString
val headers = ctx.request.headers ++ contentTypeFromEntity(ctx.request.entity)
Context(webApiDirectives, method, headers, path, query)
}
}
def routes(user: Identity)(implicit transid: TransactionId): Route = routes(Some(user))
def routes()(implicit transid: TransactionId): Route = routes(None)
private val maxWaitForWebActionResult = Some(controllerActivationConfig.maxWaitForBlockingActivation)
/**
* Adds route to web based activations. Actions invoked this way are anonymous in that the
* caller is not authenticated. The intended action must be named in the path as a fully qualified
* name as in /web/some-namespace/some-package/some-action. The package is optional
* in that the action may be in the default package, in which case, the string "default" must be used.
* If the action doesn't exist (or the namespace is not valid) NotFound is generated. Following the
* action name, an "extension" is required to specify the desired content type for the response. This
* extension is one of supported media types. An example is ".json" for a JSON response or ".html" for
* an text/html response.
*
* Optionally, the result form the action may be projected based on a named property. As in
* /web/some-namespace/some-package/some-action/some-property. If the property
* does not exist in the result then a NotFound error is generated. A path of properties may
* be supplied to project nested properties.
*
* Actions may be exposed to this web proxy by adding an annotation ("export" -> true).
*/
def routes(user: Option[Identity])(implicit transid: TransactionId): Route = {
(allowedOperations & webRoutePrefix) {
validNameSegment { namespace =>
packagePrefix { pkg =>
validNameSegment { seg =>
handleMatch(namespace, pkg, seg, user)
}
}
}
}
}
/**
* Gets identity from datastore.
* This method is factored out to allow mock testing.
*/
protected def getIdentity(namespace: EntityName)(implicit transid: TransactionId): Future[Identity] = {
// ask auth provider to create an identity for the given namespace
authenticationProvider.identityByNamespace(namespace)(transid, actorSystem, authStore)
}
private def handleMatch(namespaceSegment: String,
pkgSegment: String,
actionNameWithExtension: String,
onBehalfOf: Option[Identity])(implicit transid: TransactionId) = {
def fullyQualifiedActionName(actionName: String) = {
val namespace = EntityName(namespaceSegment)
val pkgName = if (pkgSegment == "default") None else Some(EntityName(pkgSegment))
namespace.addPath(pkgName).addPath(EntityName(actionName)).toFullyQualifiedEntityName
}
provide(WhiskWebActionsApi.mediaTranscoderForName(actionNameWithExtension, webApiDirectives.enforceExtension)) {
case (actionName, Some(extension)) =>
// extract request context, checks for overrides of reserved properties, and constructs action arguments
// as the context body which may be the incoming request when the content type is JSON or formdata, or
// the raw body as __ow_body (and query parameters as __ow_query) otherwise
extract(_.request.entity) { e =>
validateSize(isWhithinRange(e.contentLengthOption.getOrElse(0)))(transid, jsonPrettyPrinter) {
requestMethodParamsAndPath { context =>
provide(fullyQualifiedActionName(actionName)) { fullActionName =>
onComplete(verifyWebAction(fullActionName, onBehalfOf.isDefined)) {
case Success((actionOwnerIdentity, action)) =>
val requiredAuthOk =
requiredWhiskAuthSuccessful(action.annotations, context.headers).getOrElse(true)
if (!requiredAuthOk) {
logging.debug(
this,
"web action with require-whisk-auth was invoked without a matching x-require-whisk-auth header value")
terminate(Unauthorized)
} else if (!action.annotations
.getAs[Boolean](Annotations.WebCustomOptionsAnnotationName)
.getOrElse(false)) {
respondWithHeaders(defaultCorsResponse(context.headers)) {
if (context.method == OPTIONS) {
complete(OK, HttpEntity.Empty)
} else {
extractEntityAndProcessRequest(actionOwnerIdentity, action, extension, onBehalfOf, context, e)
}
}
} else {
extractEntityAndProcessRequest(actionOwnerIdentity, action, extension, onBehalfOf, context, e)
}
case Failure(t: RejectRequest) =>
terminate(t.code, t.message)
case Failure(t) =>
logging.error(this, s"exception in handleMatch: $t")
terminate(InternalServerError)
}
}
}
}
}
case (_, None) =>
terminate(NotAcceptable, Messages.contentTypeExtensionNotSupported(WhiskWebActionsApi.allowedExtensions))
}
}
/**
* Checks that subject has right to post an activation and fetch the action
* followed by the package and merge parameters. The action is fetched first since
* it will not succeed for references relative to a binding, and the export bit is
* confirmed before fetching the package and merging parameters.
*
* @return Future that completes with the action and action-owner-identity on success otherwise
* a failed future with a request rejection error which may be one of the following:
* not entitled (throttled), package/action not found, action not web enabled,
* or request overrides final parameters
*/
private def verifyWebAction(actionName: FullyQualifiedEntityName, authenticated: Boolean)(
implicit transid: TransactionId) = {
// lookup the identity for the action namespace
identityLookup(actionName.path.root) flatMap { actionOwnerIdentity =>
confirmExportedAction(actionLookup(actionName), authenticated) flatMap { a =>
checkEntitlement(actionOwnerIdentity, a) map { _ =>
(actionOwnerIdentity, a)
}
}
}
}
private def extractEntityAndProcessRequest(actionOwnerIdentity: Identity,
action: WhiskActionMetaData,
extension: MediaExtension,
onBehalfOf: Option[Identity],
context: Context,
httpEntity: HttpEntity)(implicit transid: TransactionId) = {
def process(body: Option[JsValue], isRawHttpAction: Boolean) = {
processRequest(actionOwnerIdentity, action, extension, onBehalfOf, context.withBody(body), isRawHttpAction)
}
provide(action.annotations.getAs[Boolean](Annotations.RawHttpAnnotationName).getOrElse(false)) { isRawHttpAction =>
httpEntity match {
case Empty =>
process(None, isRawHttpAction)
case HttpEntity.Strict(ct, json) if WhiskWebActionsApi.isJsonFamily(ct.mediaType) && !isRawHttpAction =>
if (json.nonEmpty) {
entity(as[JsValue]) { body =>
process(Some(body), isRawHttpAction)
}
} else {
process(None, isRawHttpAction)
}
case HttpEntity.Strict(ContentType(MediaTypes.`application/x-www-form-urlencoded`, _), _) if !isRawHttpAction =>
entity(as[FormData]) { form =>
val body = form.fields.toMap.toJson.asJsObject
process(Some(body), isRawHttpAction)
}
case HttpEntity.Strict(contentType, data) =>
// for legacy, we are encoding application/json still
if (contentType.mediaType.binary || contentType.mediaType == `application/json`) {
Try(JsString(Base64.getEncoder.encodeToString(data.toArray))) match {
case Success(bytes) => process(Some(bytes), isRawHttpAction)
case Failure(t) => terminate(BadRequest, Messages.unsupportedContentType(contentType.mediaType))
}
} else {
val str = JsString(data.utf8String)
process(Some(str), isRawHttpAction)
}
case _ => terminate(BadRequest, Messages.unsupportedContentType)
}
}
}
private def processRequest(actionOwnerIdentity: Identity,
action: WhiskActionMetaData,
responseType: MediaExtension,
onBehalfOf: Option[Identity],
context: Context,
isRawHttpAction: Boolean)(implicit transid: TransactionId) = {
def queuedActivation = {
// checks (1) if any of the query or body parameters override final action parameters
// computes overrides if any relative to the reserved __ow_* properties, and (2) if
// action is a raw http handler
//
// NOTE: it is assumed the action parameters do not intersect with the reserved properties
// since these are system properties, the action should not define them, and if it does,
// they will be overwritten
if (isRawHttpAction || context
.overrides(webApiDirectives.reservedProperties ++ action.immutableParameters)) {
val content = context.toActionArgument(onBehalfOf, isRawHttpAction)
invokeAction(actionOwnerIdentity, action, Some(JsObject(content)), maxWaitForWebActionResult, cause = None)
} else {
Future.failed(RejectRequest(BadRequest, Messages.parametersNotAllowed))
}
}
completeRequest(queuedActivation, responseType)
}
private def completeRequest(queuedActivation: Future[Either[ActivationId, WhiskActivation]],
responseType: MediaExtension)(implicit transid: TransactionId) = {
onComplete(queuedActivation) {
case Success(Right(activation)) =>
respondWithActivationIdHeader(activation.activationId) {
val result = activation.resultAsJson
if (activation.response.isSuccess || activation.response.isApplicationError) {
val resultPath = if (activation.response.isSuccess) {
List.empty
} else {
// the activation produced an error response: therefore ignore
// the requested projection and unwrap the error instead
// and attempt to handle it per the desired response type (extension)
List(ActivationResponse.ERROR_FIELD)
}
val result = getFieldPath(activation.resultAsJson, resultPath)
result match {
case Some(projection) =>
val marshaler = Future(responseType.transcoder(projection, transid, webApiDirectives))
onComplete(marshaler) {
case Success(done) => done // all transcoders terminate the connection
case Failure(t) => terminate(InternalServerError)
}
case _ => terminate(NotFound, Messages.propertyNotFound)
}
} else {
terminate(BadRequest, Messages.errorProcessingRequest)
}
}
case Success(Left(activationId)) =>
// blocking invoke which got queued instead
// this should not happen, instead it should be a blocking invoke timeout
logging.debug(this, "activation waiting period expired")
respondWithActivationIdHeader(activationId) {
terminate(Accepted, Messages.responseNotReady)
}
case Failure(t: RejectRequest) => terminate(t.code, t.message)
case Failure(t: LoadBalancerException) =>
logging.error(this, s"failed in loadbalancer: $t")
terminate(ServiceUnavailable)
case Failure(t) =>
logging.error(this, s"exception in completeRequest: $t")
terminate(InternalServerError)
}
}
/**
* Gets the action if it exists and fail future with RejectRequest if it does not.
*
* @return future action document or NotFound rejection
*/
private def actionLookup(actionName: FullyQualifiedEntityName)(
implicit transid: TransactionId): Future[WhiskActionMetaData] = {
WhiskActionMetaData.resolveActionAndMergeParameters(entityStore, actionName) recoverWith {
case _: ArtifactStoreException | DeserializationException(_, _, _) =>
Future.failed(RejectRequest(NotFound))
}
}
/**
* Gets the identity for the namespace.
*/
private def identityLookup(namespace: EntityName)(implicit transid: TransactionId): Future[Identity] = {
getIdentity(namespace) recoverWith {
case _: ArtifactStoreException | DeserializationException(_, _, _) =>
Future.failed(RejectRequest(NotFound))
case t =>
// leak nothing no matter what, failure is already logged so skip here
Future.failed(RejectRequest(NotFound))
}
}
/**
* Checks if an action is exported (i.e., carries the required annotation).
*/
private def confirmExportedAction(actionLookup: Future[WhiskActionMetaData], authenticated: Boolean)(
implicit transid: TransactionId): Future[WhiskActionMetaData] = {
actionLookup flatMap { action =>
val requiresAuthenticatedUser =
action.annotations.getAs[Boolean](Annotations.RequireWhiskAuthAnnotation).getOrElse(false)
val isExported = action.annotations.getAs[Boolean](Annotations.WebActionAnnotationName).getOrElse(false)
if ((isExported && requiresAuthenticatedUser && authenticated) ||
(isExported && !requiresAuthenticatedUser)) {
logging.debug(this, s"${action.fullyQualifiedName(true)} is exported")
Future.successful(action)
} else if (!isExported) {
logging.debug(this, s"${action.fullyQualifiedName(true)} not exported")
Future.failed(RejectRequest(NotFound))
} else {
logging.debug(this, s"${action.fullyQualifiedName(true)} requires authentication")
Future.failed(RejectRequest(Unauthorized))
}
}
}
/**
* Checks if an action is executable.
*/
private def checkEntitlement(identity: Identity, action: WhiskActionMetaData)(
implicit transid: TransactionId): Future[Unit] = {
val fqn = action.fullyQualifiedName(false)
val resource = Resource(fqn.path, collection, Some(fqn.name.asString))
entitlementProvider.check(identity, Privilege.ACTIVATE, resource)
}
/**
* Check if "require-whisk-auth" authentication is needed, and if so, authenticate the request
* NOTE: Only number or string JSON "require-whisk-auth" annotation values are supported
*
* @param annotations - web action annotations
* @param reqHeaders - web action invocation request headers
* @return Option[Boolean]
* None if annotations does not include require-whisk-auth (i.e. auth test not needed)
* Some(true) if annotations includes require-whisk-auth and it's value matches the request header `X-Require-Whisk-Auth` value
* Some(false) if annotations includes require-whisk-auth and the request does not include the header `X-Require-Whisk-Auth`
* Some(false) if annotations includes require-whisk-auth and it's value deos not match the request header `X-Require-Whisk-Auth` value
*/
private def requiredWhiskAuthSuccessful(annotations: Parameters, reqHeaders: Seq[HttpHeader]): Option[Boolean] = {
annotations
.get(Annotations.RequireWhiskAuthAnnotation)
.flatMap {
case JsString(authStr) => Some(authStr)
case JsNumber(authNum) => Some(authNum.toString)
case _ => None
}
.map { reqWhiskAuthAnnotationStr =>
reqHeaders
.find(_.is(WhiskAction.requireWhiskAuthHeader))
.map(_.value == reqWhiskAuthAnnotationStr)
.getOrElse(false) // false => when no x-require-whisk-auth header is present
}
}
}