[ 'class' => BooleanDef::class ], 'enum' => [ 'class' => EnumDef::class ], 'integer' => [ 'class' => IntegerDef::class ], 'float' => [ 'class' => FloatDef::class ], 'double' => [ 'class' => FloatDef::class ], 'NULL' => [ 'class' => StringDef::class, 'args' => [ [ StringDef::OPT_ALLOW_EMPTY => true, ] ], ], 'password' => [ 'class' => PasswordDef::class ], 'string' => [ 'class' => StringDef::class ], 'timestamp' => [ 'class' => TimestampDef::class ], 'upload' => [ 'class' => UploadDef::class ], 'expiry' => [ 'class' => ExpiryDef::class ], 'title' => [ 'class' => TitleDef::class, 'services' => [ 'TitleFactory' ], ], 'user' => [ 'class' => UserDef::class, 'services' => [ 'UserIdentityLookup', 'TitleParser', 'UserNameUtils' ] ], 'array' => [ 'class' => ArrayDef::class, ], ]; /** @var string[] HTTP request methods that we expect never to have a payload */ private const NO_BODY_METHODS = [ 'GET', 'HEAD' ]; /** @var string[] HTTP request methods that we expect always to have a payload */ private const BODY_METHODS = [ 'POST', 'PUT' ]; // NOTE: per RFC 7231 (https://www.rfc-editor.org/rfc/rfc7231#section-4.3.5), sending a body // with the DELETE method "has no defined semantics". We allow it, as it is useful for // passing the csrf token required by some authentication methods. /** @var string[] Content types handled via $_POST */ private const FORM_DATA_CONTENT_TYPES = [ 'application/x-www-form-urlencoded', 'multipart/form-data', ]; private ParamValidator $paramValidator; /** * @param ObjectFactory $objectFactory * @param RequestInterface $request * @param Authority $authority * @internal */ public function __construct( ObjectFactory $objectFactory, RequestInterface $request, Authority $authority ) { $this->paramValidator = new ParamValidator( new ParamValidatorCallbacks( $request, $authority ), $objectFactory, [ 'typeDefs' => self::TYPE_DEFS, ] ); } /** * Validate parameters. * Params with the source specified as 'body' will be ignored. * Use validateBodyParams() for these. * * @see validateBodyParams * @param array[] $paramSettings Parameter settings * @return array Validated parameters * @throws HttpException on validation failure */ public function validateParams( array $paramSettings ) { $validatedParams = []; foreach ( $paramSettings as $name => $settings ) { try { $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified'; if ( $source === 'body' ) { continue; } $validatedParams[$name] = $this->paramValidator->getValue( $name, $settings, [ 'source' => $source, ] ); } catch ( ValidationException $e ) { // NOTE: error data structure must match the one used by validateBodyParams throw new LocalizedHttpException( $e->getFailureMessage(), 400, [ 'error' => 'parameter-validation-failed', 'name' => $e->getParamName(), 'value' => $e->getParamValue(), 'failureCode' => $e->getFailureMessage()->getCode(), 'failureData' => $e->getFailureMessage()->getData(), ] ); } } return $validatedParams; } /** * Throw an HttpException if there are unexpected body fields. * * Note that this will ignore all body fields if $paramSettings does not * declare any body parameters, to avoid failures when clients send spurious * data to handlers that do not support body validation at all. This * behavior may change in the future. * * @param array $paramSettings * @param array $parsedBody * * @throws LocalizedHttpException if there are unexpected body fields. */ public function detectExtraneousBodyFields( array $paramSettings, array $parsedBody ) { $validatedKeys = []; $remainingBodyFields = $parsedBody; foreach ( $paramSettings as $name => $settings ) { $source = $settings[Handler::PARAM_SOURCE] ?? 'unspecified'; if ( $source !== 'body' ) { continue; } $validatedKeys[] = $name; unset( $remainingBodyFields[$name] ); } $unvalidatedKeys = array_keys( $remainingBodyFields ); // Throw if there are unvalidated keys left and there are body params defined. // If there are no known body params declared, we just ignore any body // data coming from the client. This works around that fact that "post" // params also show up in the parsed body. That means that mixing "body" // and "post" params will trigger an error here. Any "post" params should // be converted to "body". if ( $validatedKeys && $unvalidatedKeys ) { throw new LocalizedHttpException( new MessageValue( 'rest-extraneous-body-fields', [ new ListParam( ListType::COMMA, $unvalidatedKeys ) ] ), 400, [ // match fields used by validateBodyParams() 'error' => 'parameter-validation-failed', 'failureCode' => 'extraneous-body-fields', 'name' => reset( $unvalidatedKeys ), 'failureData' => $unvalidatedKeys, ] ); } } /** * Validate body fields. * Only params with the source specified as 'body' will be processed, * use validateParams() for parameters coming from the path, from query, etc. * * @since 1.42 * * @see validateParams * @see validateBody * @param array[] $paramSettings Parameter settings. * @param bool $enforceTypes $enforceTypes Whether the types of primitive values should * be enforced. If set to false, parameters values are allowed to be * strings. * @return array Validated parameters * @throws HttpException on validation failure */ public function validateBodyParams( array $paramSettings, bool $enforceTypes = true ) { $validatedParams = []; foreach ( $paramSettings as $name => $settings ) { $source = $settings[Handler::PARAM_SOURCE] ?? 'body'; if ( $source !== 'body' ) { continue; } try { $validatedParams[ $name ] = $this->paramValidator->getValue( $name, $settings, [ 'source' => $source, TypeDef::OPT_ENFORCE_JSON_TYPES => $enforceTypes, StringDef::OPT_ALLOW_EMPTY => $enforceTypes, ] ); } catch ( ValidationException $e ) { $msg = $e->getFailureMessage(); $wrappedMsg = new MessageValue( 'rest-body-validation-error', [ $e->getFailureMessage() ] ); // NOTE: error data structure must match the one used by validateParams throw new LocalizedHttpException( $wrappedMsg, 400, [ 'error' => 'parameter-validation-failed', 'name' => $e->getParamName(), 'value' => $e->getParamValue(), 'failureCode' => $msg->getCode(), 'failureData' => $msg->getData(), ] ); } } return $validatedParams; } /** * Validate the body of a request. * * This may return a data structure representing the parsed body. When used * in the context of Handler::validateParams(), the returned value will be * available to the handler via Handler::getValidatedBody(). * * @deprecated since 1.43, use validateBodyParams instead. * * @param RequestInterface $request * @param Handler $handler Used to call {@see Handler::getBodyValidator} * @return mixed|null Return value from {@see BodyValidator::validateBody} * @throws HttpException on validation failure */ public function validateBody( RequestInterface $request, Handler $handler ) { wfDeprecated( __METHOD__, '1.43' ); $method = strtoupper( trim( $request->getMethod() ) ); // If the method should never have a body, don't bother validating. if ( in_array( $method, self::NO_BODY_METHODS, true ) ) { return null; } // Get the content type [ $ct ] = explode( ';', $request->getHeaderLine( 'Content-Type' ), 2 ); $ct = strtolower( trim( $ct ) ); if ( $ct === '' ) { // No Content-Type was supplied. RFC 7231 ยง 3.1.1.5 allows this, but // since it's probably a client error let's return a 415, unless the // body is known to be empty. $body = $request->getBody(); if ( $body->getSize() === 0 ) { return null; } else { throw new LocalizedHttpException( new MessageValue( "rest-requires-content-type-header" ), 415, [ 'error' => 'no-content-type', ] ); } } // Form data is parsed into $_POST and $_FILES by PHP and from there is accessed as parameters, // don't bother trying to handle these via BodyValidator too. if ( in_array( $ct, RequestInterface::FORM_DATA_CONTENT_TYPES, true ) ) { return null; } // Validate the body. BodyValidator throws an HttpException on failure. return $handler->getBodyValidator( $ct )->validateBody( $request ); } private const PARAM_TYPE_SCHEMAS = [ 'boolean-param' => [ 'type' => 'boolean' ], 'enum-param' => [ 'type' => 'string' ], 'integer-param' => [ 'type' => 'integer' ], 'float-param' => [ 'type' => 'number', 'format' => 'float' ], 'double-param' => [ 'type' => 'number', 'format' => 'double' ], // 'NULL-param' => [ 'type' => 'null' ], // FIXME 'password-param' => [ 'type' => 'string' ], 'string-param' => [ 'type' => 'string' ], 'timestamp-param' => [ 'type' => 'string', 'format' => 'mw-timestamp' ], 'upload-param' => [ 'type' => 'string', 'format' => 'mw-upload' ], 'expiry-param' => [ 'type' => 'string', 'format' => 'mw-expiry' ], 'title-param' => [ 'type' => 'string', 'format' => 'mw-title' ], 'user-param' => [ 'type' => 'string', 'format' => 'mw-user' ], 'array-param' => [ 'type' => 'object' ], ]; /** * Returns JSON Schema description of all known parameter types. * The name of the schema is the name of the parameter type with "-param" appended. * * @see https://swagger.io/specification/#schema-object * @see self::TYPE_DEFS * * @return array */ public static function getParameterTypeSchemas(): array { return self::PARAM_TYPE_SCHEMAS; } /** * Convert a param settings array into an OpenAPI Parameter Object specification structure. * @see https://swagger.io/specification/#parameter-object * * @param string $name * @param array $paramSetting * * @return array */ public static function getParameterSpec( string $name, array $paramSetting ): array { $schema = self::getParameterSchema( $paramSetting ); // TODO: generate a warning if the source is not specified! $location = $paramSetting[ self::PARAM_SOURCE ] ?? 'unspecified'; $param = [ 'name' => $name, 'description' => $paramSetting[ self::PARAM_DESCRIPTION ] ?? "$name parameter", 'in' => $location, 'schema' => $schema ]; // TODO: generate a warning if required is false for a pth param $param['required'] = $location === 'path' || ( $paramSetting[ ParamValidator::PARAM_REQUIRED ] ?? false ); return $param; } /** * Convert a param settings array into an OpenAPI schema structure. * @see https://swagger.io/specification/#schema-object * * @param array $paramSetting * * @return array */ public static function getParameterSchema( array $paramSetting ): array { $type = $paramSetting[ ParamValidator::PARAM_TYPE ] ?? 'string'; if ( is_array( $type ) ) { if ( $type === [] ) { // Hack for empty enums. In path and query parameters, // the empty string is often the same as "no value". // TODO: generate a warning! $type = [ '' ]; } $schema = [ 'type' => 'string', 'enum' => $type ]; } elseif ( isset( $paramSetting[ ArrayDef::PARAM_SCHEMA ] ) ) { $schema = $paramSetting[ ArrayDef::PARAM_SCHEMA ]; } else { // TODO: multi-value params?! $schema = self::PARAM_TYPE_SCHEMAS["{$type}-param"] ?? []; } return $schema; } }