User:APaskulin (WMF)/Swagger annotations

From MediaWiki.org
Jump to navigation Jump to search

This page shows an example of using the zircote/swagger-php tool to generate API docs from code annotations.

Notes[edit]

  • The tool requires adding a significant number of lines to code files. In this example: 100 lines added to the handler and 12 lines added to the entry point, compared with 84 lines in the resulting spec.
  • The tool supports context-aware annotations, but it seems that our handlers don't work the same way as the examples.
  • The sandbox feature isn't working in Swagger Editor using this example. However, the generated requests are correct, so I assume the sandbox can be fixed.
  • This example doesn't include error schemas, which I expect could be constructed using variables.
  • Need to look into using oneOf to document multiple responses per response code.
  • This example separates the annotations into two blocks: one with the path and responses and one with the response schema. To break up theses 50+ line blocks, it would be nice to place doc annotations as close as possible to the code they describe. However, the tool requires responses to be in the same annotation block as the path definition. Schema properties can be distributed, but it requires extra annotation syntax, adding complexity and further code file bloat. See this example.

Tool usage[edit]

compose update

composer require zircote/swagger-php

./vendor/bin/openapi -o swagger-output.yaml includes/Rest

Example source[edit]

EntryPoint.php[edit]

/**
 * @OA\OpenApi(
 *     @OA\Info(
 *         version="1.0.0",
 *         title="MediaWiki REST API",
 *         @OA\License(name="MIT")
 *     ),
 *     @OA\Server(
 *         description="Beta server",
 *         url="https://en.wikipedia.beta.wmflabs.org/w/rest.php/",
 *     ),
 * )
 */

PageSourceHandler.php[edit]

<?php

namespace MediaWiki\Rest\Handler;

use Config;
use MediaWiki\Permissions\PermissionManager;
use MediaWiki\Rest\LocalizedHttpException;
use MediaWiki\Rest\Response;
use MediaWiki\Rest\SimpleHandler;
use MediaWiki\Revision\RevisionAccessException;
use MediaWiki\Revision\RevisionLookup;
use MediaWiki\Revision\RevisionRecord;
use MediaWiki\Revision\SlotRecord;
use MediaWiki\Revision\SuppressedDataException;
use RequestContext;
use TextContent;
use Title;
use TitleFormatter;
use User;
use Wikimedia\Message\MessageValue;
use Wikimedia\Message\ParamType;
use Wikimedia\Message\ScalarParam;
use Wikimedia\ParamValidator\ParamValidator;

/**
 * Handler class for Core REST API Page Source endpoint
 * 
 * @OA\Get(
 *     path="/coredev/v0/page/{title}",
 *     summary="Get page wikitext", 
 * 	   @OA\Parameter(
 * 	      name="title",
 *        in="path",
 *        description="Page title",
 *        required=true,
 *        example="Main_Page",
 *        @OA\Schema(
 *  	    type="string"
 *        )
 *     ),
 *     @OA\Response(
 * 		   response=200,
 *         description="A page source object with wikitext source and license information",
 *         @OA\JsonContent(ref="#/components/schemas/Page")
 *     ),
 *     @OA\Response(
 * 		   response=403,
 *         description="User is not permitted to view this page."
 *     ),
 *     @OA\Response(
 * 		   response=404,
 *         description="Title not found, or no revision exists for this title."
 *     )
 * )
 */

class PageSourceHandler extends SimpleHandler {
	private const MAX_AGE_200 = 5;

	/** @var RevisionLookup */
	private $revisionLookup;

	/** @var TitleFormatter */
	private $titleFormatter;

	/** @var PermissionManager */
	private $permissionManager;

	/** @var User */
	private $user;

	/** @var Config */
	private $config;

	/** @var RevisionRecord|bool */
	private $revision;

	/** @var Title */
	private $titleObject;

	/**
	 * @param RevisionLookup $revisionLookup
	 * @param TitleFormatter $titleFormatter
	 * @param PermissionManager $permissionManager
	 * @param Config $config
	 */
	public function __construct(
		RevisionLookup $revisionLookup,
		TitleFormatter $titleFormatter,
		PermissionManager $permissionManager,
		Config $config
	) {
		$this->revisionLookup = $revisionLookup;
		$this->titleFormatter = $titleFormatter;
		$this->permissionManager = $permissionManager;
		$this->config = $config;

		// @todo Inject this, when there is a good way to do that
		$this->user = RequestContext::getMain()->getUser();
	}

	// Default to main slot
	private function getRole() {
		return SlotRecord::MAIN;
	}


	/**
	 * @param RevisionRecord $revision
	 * @return TextContent $content
	 * @throws LocalizedHttpException slot content is not TextContent or Revision/Slot is inaccessible
	 */
	private function getPageContent( RevisionRecord $revision ) {
		try {
			$content = $revision
				->getSlot( $this->getRole(), RevisionRecord::FOR_THIS_USER, $this->user )
				->getContent()
				->convert( CONTENT_MODEL_TEXT );
			if ( !( $content instanceof TextContent ) ) {
				throw new LocalizedHttpException( new MessageValue( 'rest-page-source-type-error' ), 400 );
			}
		} catch ( SuppressedDataException $e ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-permission-denied-revision',
					[ new ScalarParam( ParamType::NUM, $revision->getId() ) ]
				),
				403
			);
		} catch ( RevisionAccessException $e ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-nonexistent-revision',
					[ new ScalarParam( ParamType::NUM, $revision->getId() ) ]
				),
				404
			);
		}
		return $content;
	}

	private function isAccessible( $titleObject ) {
		return $this->permissionManager->userCan( 'read', $this->user, $titleObject );
	}

	/**
	 * @param string $title
	 * @return Response
	 * @throws LocalizedHttpException
	 */
	public function run( $title ) {
		$titleObject = $this->getTitle();
		if ( !$titleObject || !$titleObject->getArticleID() ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-nonexistent-title',
					[ new ScalarParam( ParamType::TEXT, $title ) ]
				),
				404
			);
		}
		if ( !$this->isAccessible( $titleObject ) ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-permission-denied-title',
					[ new ScalarParam( ParamType::TEXT, $title ) ]
				),
				403
			);
		}
		$revision = $this->getRevision();
		if ( !$revision ) {
			throw new LocalizedHttpException(
				new MessageValue( 'rest-no-revision',
					[ new ScalarParam( ParamType::TEXT, $title ) ]
				),
				404
			);
		}
		$content = $this->getPageContent( $this->revision );
		/**
		 * @OA\Schema(
		 *   schema="Page",
		 * 	 description="Page object, including license information and page source in wikitext format",
		 *   @OA\Property(
		 *     property="id",
		 *     type="integer",
		 *     description="Page ID",
		 *     example="825459"
		 *   ),
		 *   @OA\Property(
		 *     property="key",
		 *     type="string",
		 *     description="Page title in DB format",
		 *     example="Main_Page"
		 *   ),
		 *   @OA\Property(
		 *     property="title",
		 *     type="string",
		 *     description="Page title in display format",
		 *     example="Main Page"
		 *   ),
		 *   @OA\Property(
		 *     property="latest",
		 *     type="object",
		 *     description="Information about the latest revision of the page",
		 *     @OA\Property(
		 *       property="id",
		 *       type="integer",
		 *       description="ID of the latest revision",
		 *       example="355633"
		 *     ),
		 *     @OA\Property(
		 *       property="timestamp",
		 *       type="integer",
		 *       description="Time of the latest revision in ISO 8601 format",
		 *       example="2019-02-01T16:31:57Z"
		 *     )
		 *   ),
		 *   @OA\Property(
		 *     property="content_model",
		 *     type="string",
		 *     description="Text",
		 *     example="text"
		 *   ),
		 *   @OA\Property(
		 *     property="license",
		 *     type="object",
		 *     description="Information about the page license",
		 *     @OA\Property(
		 *       property="url",
		 *       type="string",
		 *       description="License URL",
		 *       example="//creativecommons.org/licenses/by-sa/3.0/"
		 *     ),
		 *     @OA\Property(
		 *       property="title",
		 *       type="string",
		 *       description="License title",
		 *       example="Creative Commons Attribution-Share Alike 3.0"
		 *     )
		 *   ),
		 *   @OA\Property(
		 *     property="source",
		 *     type="string",
		 *     description="Page source in wikitext format",
		 *     example="Hello, and welcome to the WMF integration environment known as Beta Cluster.\n\n"
		 *   )
		 * )
		 */
		$body = [
			'id' => $titleObject->getArticleID(),
			'key' => $this->titleFormatter->getPrefixedDbKey( $this->titleObject ),
			'title' => $this->titleFormatter->getPrefixedText( $this->titleObject ),
			'latest' => [
				'id' => $this->revision->getId(),
				'timestamp' => wfTimestampOrNull( TS_ISO_8601, $this->revision->getTimestamp() )
			],
			'content_model' => $content->getModel(),
			'license' => [
				'url' => $this->config->get( 'RightsUrl' ),
				'title' => $this->config->get( 'RightsText' )
			],
			'source' => $content->getText()
		];

		$response = $this->getResponseFactory()->createJson( $body );
		$response->setHeader( 'Cache-Control', 'maxage=' . self::MAX_AGE_200 );
		return $response;
	}

	public function needsWriteAccess() {
		return false;
	}

	/**
	 * @return RevisionRecord|bool latest revision or false if unable to retrieve revision
	 */
	private function getRevision() {
		if ( $this->revision === null ) {
			$title = $this->getTitle();
			if ( $title && $title->getArticleID() ) {
				$this->revision = $this->revisionLookup->getKnownCurrentRevision( $title );
			} else {
				$this->revision = false;
			}
		}
		return $this->revision;
	}

	/**
	 * @return Title|bool Title or false if unable to retrieve title
	 */
	private function getTitle() {
		if ( $this->titleObject === null ) {
			$this->titleObject = Title::newFromText( $this->getValidatedParams()['title'] ) ?? false;
		}
		return $this->titleObject;
	}

	/**
	 * Returns an ETag representing a page's source. The ETag assumes a page's source has changed
	 * if the latest revision of a page has been made private, un-readable for another reason,
	 * or a newer revision exists.
	 * @return string
	 */
	protected function getETag() {
		$revision = $this->getRevision();
		$latestRevision = $revision ? $revision->getID() : 'e0';

		$isAccessible = $this->isAccessible( $this->getTitle() );
		$accessibleTag = $isAccessible ? 'a1' : 'a0';

		$revisionTag = $latestRevision . $accessibleTag;
		return '"' . sha1( "$revisionTag" ) . '"';
	}

	/**
	 * @return string|null
	 */
	protected function getLastModified() {
		$revision = $this->getRevision();
		if ( $revision ) {
			return $this->revision->getTimestamp();
		}
	}

	 public function getParamSettings() {
		return [
			'title' => [
				self::PARAM_SOURCE => 'path',
				ParamValidator::PARAM_TYPE => 'string',
				ParamValidator::PARAM_REQUIRED => true,
			],
		];
	}
}

Alternative annotation organization[edit]

Here's an example of an individual property described separately and pulled into the parent schema:

$content = $this->getPageContent( $this->revision );
/**
    * @OA\Schema(
    *   schema="Page",
    * 	description="Page object, including license information and page source in wikitext format",
    *   allOf={
    *     @OA\Schema(
    *       @OA\Property(property="id", ref="#/components/schemas/pageid/properties/id")
    *     )
    *   }
    * )
    */
$body = [
    /**
        * @OA\Schema(
        *   schema="pageid",
        *   @OA\Property(
        *     property="id",
        *     type="integer",
        *     description="Page ID",
        *     example="927264"
        *   )
        * )
        */
    'id' => $titleObject->getArticleID(),
     ...

Compared with:

$content = $this->getPageContent( $this->revision );
/**
    * @OA\Schema(
    *   schema="Page",
    * 	description="Page object, including license information and page source in wikitext format",
    *   @OA\Property(
    *     property="id",
    *     type="integer",
    *     description="Page ID",
    *     example="825459"
    *   )
    * )
    */
$body = [
    'id' => $titleObject->getArticleID(),
    ...

Swagger output[edit]

Paste into Swagger Editor to see rendered output.

openapi: 3.0.0
info:
  title: 'MediaWiki REST API'
  license:
    name: MIT
  version: 1.0.0
servers:
  -
    url: 'https://en.wikipedia.beta.wmflabs.org/w/rest.php/'
    description: 'Beta server'
paths:
  '/coredev/v0/page/{title}':
    get:
      summary: 'Get page wikitext'
      parameters:
        -
          name: title
          in: path
          description: 'Page title'
          required: true
          schema:
            type: string
          example: Main_Page
      responses:
        '200':
          description: 'A page source object with wikitext source and license information'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Page'
        '403':
          description: 'User is not permitted to view this page.'
        '404':
          description: 'Title not found, or no revision exists for this title.'
components:
  schemas:
    Page:
      description: 'Page object, including license information and page source in wikitext format'
      properties:
        id:
          description: 'Page ID'
          type: integer
          example: '825459'
        key:
          description: 'Page title in DB format'
          type: string
          example: Main_Page
        title:
          description: 'Page title in display format'
          type: string
          example: 'Main Page'
        latest:
          description: 'Information about the latest revision of the page'
          properties:
            id:
              description: 'ID of the latest revision'
              type: integer
              example: '355633'
            timestamp:
              description: 'Time of the latest revision in ISO 8601 format'
              type: integer
              example: '2019-02-01T16:31:57Z'
          type: object
        content_model:
          description: Text
          type: string
          example: text
        license:
          description: 'Information about the page license'
          properties:
            url:
              description: 'License URL'
              type: string
              example: //creativecommons.org/licenses/by-sa/3.0/
            title:
              description: 'License title'
              type: string
              example: 'Creative Commons Attribution-Share Alike 3.0'
          type: object
        source:
          description: 'Page source in wikitext format'
          type: string
          example: 'Hello, and welcome to the WMF integration environment known as Beta Cluster.\n\n'
      type: object