| /* |
| * 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 |
| } |
| } |
| } |