Skip to content

[4.1] Media webservice #2128

@jgerman-bot

Description

@jgerman-bot

New language relevant PR in upstream repo: joomla/joomla-cms#35788 Here are the upstream changes:

Click to expand the diff!
diff --git a/administrator/language/en-GB/plg_webservices_media.ini b/administrator/language/en-GB/plg_webservices_media.ini
new file mode 100644
index 000000000000..b2b25bba1114
--- /dev/null
+++ b/administrator/language/en-GB/plg_webservices_media.ini
@@ -0,0 +1,7 @@
+; Joomla! Project
+; (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WEBSERVICES_MEDIA="Web Services - Media"
+PLG_WEBSERVICES_MEDIA_XML_DESCRIPTION="Add media routes to the API for your website."
diff --git a/administrator/language/en-GB/plg_webservices_media.sys.ini b/administrator/language/en-GB/plg_webservices_media.sys.ini
new file mode 100644
index 000000000000..b2b25bba1114
--- /dev/null
+++ b/administrator/language/en-GB/plg_webservices_media.sys.ini
@@ -0,0 +1,7 @@
+; Joomla! Project
+; (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+PLG_WEBSERVICES_MEDIA="Web Services - Media"
+PLG_WEBSERVICES_MEDIA_XML_DESCRIPTION="Add media routes to the API for your website."
diff --git a/api/components/com_media/src/Controller/AdaptersController.php b/api/components/com_media/src/Controller/AdaptersController.php
new file mode 100644
index 000000000000..c8c23df127dc
--- /dev/null
+++ b/api/components/com_media/src/Controller/AdaptersController.php
@@ -0,0 +1,63 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Controller;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\MVC\Controller\ApiController;
+use Joomla\Component\Media\Administrator\Exception\InvalidPathException;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service controller.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class AdaptersController extends ApiController
+{
+	use AdapterTrait;
+
+	/**
+	 * The content type of the item.
+	 *
+	 * @var    string
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $contentType = 'adapters';
+
+	/**
+	 * The default view for the display method.
+	 *
+	 * @var    string
+	 *
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $default_view = 'adapters';
+
+	/**
+	 * Display one specific adapter.
+	 *
+	 * @param   string  $path  The path of the file to display. Leave empty if you want to retrieve data from the request.
+	 *
+	 * @return  static  A \JControllerLegacy object to support chaining.
+	 *
+	 * @throws  InvalidPathException
+	 * @throws  \Exception
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function displayItem($path = '')
+	{
+		// Set the id as the parent sets it as int
+		$this->modelState->set('id', $this->input->get('id', '', 'string'));
+
+		return parent::displayItem();
+	}
+}
diff --git a/api/components/com_media/src/Controller/MediaController.php b/api/components/com_media/src/Controller/MediaController.php
new file mode 100644
index 000000000000..379d275f99be
--- /dev/null
+++ b/api/components/com_media/src/Controller/MediaController.php
@@ -0,0 +1,410 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Controller;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\Access\Exception\NotAllowed;
+use Joomla\CMS\Component\ComponentHelper;
+use Joomla\CMS\Filter\InputFilter;
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\Controller\ApiController;
+use Joomla\Component\Media\Administrator\Exception\FileExistsException;
+use Joomla\Component\Media\Administrator\Exception\InvalidPathException;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+use Joomla\Component\Media\Api\Model\MediumModel;
+use Joomla\String\Inflector;
+use Tobscure\JsonApi\Exception\InvalidParameterException;
+
+/**
+ * Media web service controller.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class MediaController extends ApiController
+{
+	use AdapterTrait;
+
+	/**
+	 * The content type of the item.
+	 *
+	 * @var    string
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $contentType = 'media';
+
+	/**
+	 * Query parameters => model state mappings
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private static $listQueryModelStateMap = [
+		'path'    => [
+			'name' => 'path',
+			'type' => 'STRING',
+		],
+		'url'     => [
+			'name' => 'url',
+			'type' => 'BOOLEAN',
+		],
+		'temp'    => [
+			'name' => 'temp',
+			'type' => 'BOOLEAN',
+		],
+		'content' => [
+			'name' => 'content',
+			'type' => 'BOOLEAN',
+		],
+	];
+
+	/**
+	 * Item query parameters => model state mappings
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private static $itemQueryModelStateMap = [
+		'path'    => [
+			'name' => 'path',
+			'type' => 'STRING',
+		],
+		'url'     => [
+			'name' => 'url',
+			'type' => 'BOOLEAN',
+		],
+		'temp'    => [
+			'name' => 'temp',
+			'type' => 'BOOLEAN',
+		],
+		'content' => [
+			'name' => 'content',
+			'type' => 'BOOLEAN',
+		],
+	];
+
+	/**
+	 * The default view for the display method.
+	 *
+	 * @var    string
+	 *
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $default_view = 'media';
+
+	/**
+	 * Display a list of files and/or folders.
+	 *
+	 * @return  static  A \JControllerLegacy object to support chaining.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  \Exception
+	 */
+	public function displayList()
+	{
+		// Set list specific request parameters in model state.
+		$this->setModelState(self::$listQueryModelStateMap);
+
+		// Display files in specific path.
+		if ($this->input->exists('path'))
+		{
+			$this->modelState->set('path', $this->input->get('path', '', 'STRING'));
+		}
+
+		// Return files (not folders) as urls.
+		if ($this->input->exists('url'))
+		{
+			$this->modelState->set('url', $this->input->get('url', true, 'BOOLEAN'));
+		}
+
+		// Map JSON:API compliant filter[search] to com_media model state.
+		$apiFilterInfo = $this->input->get('filter', [], 'array');
+		$filter        = InputFilter::getInstance();
+
+		// Search for files matching (part of) a name or glob pattern.
+		if ($doSearch = array_key_exists('search', $apiFilterInfo))
+		{
+			$this->modelState->set('search', $filter->clean($apiFilterInfo['search'], 'STRING'));
+
+			// Tell model to search recursively
+			$this->modelState->set('search_recursive', $this->input->get('search_recursive', false, 'BOOLEAN'));
+		}
+
+		return parent::displayList();
+	}
+
+	/**
+	 * Display one specific file or folder.
+	 *
+	 * @param   string  $path  The path of the file to display. Leave empty if you want to retrieve data from the request.
+	 *
+	 * @return  static  A \JControllerLegacy object to support chaining.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  InvalidPathException
+	 * @throws  \Exception
+	 */
+	public function displayItem($path = '')
+	{
+		// Set list specific request parameters in model state.
+		$this->setModelState(self::$itemQueryModelStateMap);
+
+		// Display files in specific path.
+		$this->modelState->set('path', $path ?: $this->input->get('path', '', 'STRING'));
+
+		// Return files (not folders) as urls.
+		if ($this->input->exists('url'))
+		{
+			$this->modelState->set('url', $this->input->get('url', true, 'BOOLEAN'));
+		}
+
+		return parent::displayItem();
+	}
+
+	/**
+	 * Set model state using a list of mappings between query parameters and model state names.
+	 *
+	 * @param   array  $mappings  A list of mappings between query parameters and model state names..
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function setModelState(array $mappings): void
+	{
+		foreach ($mappings as $queryName => $modelState)
+		{
+			if ($this->input->exists($queryName))
+			{
+				$this->modelState->set($modelState['name'], $this->input->get($queryName, '', $modelState['type']));
+			}
+		}
+	}
+
+	/**
+	 * Method to add a new file or folder.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  FileExistsException
+	 * @throws  InvalidPathException
+	 * @throws  InvalidParameterException
+	 * @throws  \RuntimeException
+	 * @throws  \Exception
+	 */
+	public function add(): void
+	{
+		$path = $this->input->json->get('path', '', 'STRING');
+		$content = $this->input->json->get('content', '', 'RAW');
+
+		$missingParameters = [];
+
+		if (empty($path))
+		{
+			$missingParameters[] = 'path';
+		}
+
+		// Content is only required when it is a file
+		if (empty($content) && strpos($path, '.') !== false)
+		{
+			$missingParameters[] = 'content';
+		}
+
+		if (\count($missingParameters))
+		{
+			throw new InvalidParameterException(
+				Text::sprintf('WEBSERVICE_COM_MEDIA_MISSING_REQUIRED_PARAMETERS', implode(' & ', $missingParameters))
+			);
+		}
+
+		$this->modelState->set('path', $this->input->json->get('path', '', 'STRING'));
+
+		// Check if an existing file may be overwritten. Defaults to false.
+		$this->modelState->set('override', $this->input->json->get('override', false));
+
+		parent::add();
+	}
+
+	/**
+	 * Method to check if it's allowed to add a new file or folder
+	 *
+	 * @param   array  $data  An array of input data.
+	 *
+	 * @return  boolean
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	protected function allowAdd($data = array()): bool
+	{
+		$user = $this->app->getIdentity();
+
+		return $user->authorise('core.create', 'com_media');
+	}
+
+	/**
+	 * Method to modify an existing file or folder.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  FileExistsException
+	 * @throws  InvalidPathException
+	 * @throws  \RuntimeException
+	 * @throws  \Exception
+	 */
+	public function edit(): void
+	{
+		// Access check.
+		if (!$this->allowEdit())
+		{
+			throw new NotAllowed('JLIB_APPLICATION_ERROR_CREATE_RECORD_NOT_PERMITTED', 403);
+		}
+
+		$path = $this->input->json->get('path', '', 'STRING');
+		$content = $this->input->json->get('content', '', 'RAW');
+
+		if (empty($path) && empty($content))
+		{
+			throw new InvalidParameterException(
+				Text::sprintf('WEBSERVICE_COM_MEDIA_MISSING_REQUIRED_PARAMETERS', 'path | content')
+			);
+		}
+
+		$this->modelState->set('path', $this->input->json->get('path', '', 'STRING'));
+		// For renaming/moving files, we need the path to the existing file or folder.
+		$this->modelState->set('old_path', $this->input->get('path', '', 'STRING'));
+		// Check if an existing file may be overwritten. Defaults to true.
+		$this->modelState->set('override', $this->input->json->get('override', true));
+
+		$recordId = $this->save();
+
+		$this->displayItem($recordId);
+	}
+
+	/**
+	 * Method to check if it's allowed to modify an existing file or folder.
+	 *
+	 * @param   array  $data  An array of input data.
+	 *
+	 * @return  boolean
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	protected function allowEdit($data = array(), $key = 'id'): bool
+	{
+		$user = $this->app->getIdentity();
+
+		// com_media's access rules contains no specific update rule.
+		return $user->authorise('core.edit', 'com_media');
+	}
+
+	/**
+	 * Method to create or modify a file or folder.
+	 *
+	 * @param   integer  $recordKey  The primary key of the item (if exists)
+	 *
+	 * @return  string   The path
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	protected function save($recordKey = null)
+	{
+		// Explicitly get the single item model name.
+		$modelName = $this->input->get('model', Inflector::singularize($this->contentType));
+
+		/** @var MediumModel $model */
+		$model = $this->getModel($modelName, '', ['ignore_request' => true, 'state' => $this->modelState]);
+
+		$json = $this->input->json;
+
+		// Decode content, if any
+		if ($content = base64_decode($json->get('content', '', 'raw')))
+		{
+			$this->checkContent();
+		}
+
+		// If there is no content, com_media assumes the path refers to a folder.
+		$this->modelState->set('content', $content);
+
+		return $model->save();
+	}
+
+	/**
+	 * Performs various checks to see if it is allowed to save the content.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  \RuntimeException
+	 */
+	private function checkContent(): void
+	{
+		$params       = ComponentHelper::getParams('com_media');
+		$helper       = new \Joomla\CMS\Helper\MediaHelper();
+		$serverlength = $this->input->server->getInt('CONTENT_LENGTH');
+
+		// Check if the size of the request body does not exceed various server imposed limits.
+		if (($params->get('upload_maxsize', 0) > 0 && $serverlength > ($params->get('upload_maxsize', 0) * 1024 * 1024))
+			|| $serverlength > $helper->toBytes(ini_get('upload_max_filesize'))
+			|| $serverlength > $helper->toBytes(ini_get('post_max_size'))
+			|| $serverlength > $helper->toBytes(ini_get('memory_limit')))
+		{
+			throw new \RuntimeException(Text::_('COM_MEDIA_ERROR_WARNFILETOOLARGE'), 400);
+		}
+	}
+
+	/**
+	 * Method to delete an existing file or folder.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  InvalidPathException
+	 * @throws  \RuntimeException
+	 * @throws  \Exception
+	 */
+	public function delete($id = null): void
+	{
+		if (!$this->allowDelete())
+		{
+			throw new NotAllowed('JLIB_APPLICATION_ERROR_DELETE_NOT_PERMITTED', 403);
+		}
+
+		$this->modelState->set('path', $this->input->get('path', '', 'STRING'));
+
+		$modelName = $this->input->get('model', Inflector::singularize($this->contentType));
+		$model     = $this->getModel($modelName, '', ['ignore_request' => true, 'state' => $this->modelState]);
+
+		$model->delete();
+
+		$this->app->setHeader('status', 204);
+	}
+
+	/**
+	 * Method to check if it's allowed to delete an existing file or folder.
+	 *
+	 * @return  boolean
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	protected function allowDelete(): bool
+	{
+		$user = $this->app->getIdentity();
+
+		return $user->authorise('core.delete', 'com_media');
+	}
+}
diff --git a/api/components/com_media/src/Helper/AdapterTrait.php b/api/components/com_media/src/Helper/AdapterTrait.php
new file mode 100644
index 000000000000..54155908cef0
--- /dev/null
+++ b/api/components/com_media/src/Helper/AdapterTrait.php
@@ -0,0 +1,169 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Helper;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\Component\ComponentHelper;
+use Joomla\CMS\Factory;
+use Joomla\CMS\Plugin\PluginHelper;
+use Joomla\Component\Media\Administrator\Adapter\AdapterInterface;
+use Joomla\Component\Media\Administrator\Event\MediaProviderEvent;
+use Joomla\Component\Media\Administrator\Provider\ProviderInterface;
+use Joomla\Component\Media\Administrator\Provider\ProviderManager;
+
+/**
+ * Trait for classes that need adapters.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+trait AdapterTrait
+{
+	/**
+	 * Holds the available media file adapters.
+	 *
+	 * @var    ProviderManager
+	 *
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $providerManager = null;
+
+	/**
+	 * The default adapter name.
+	 *
+	 * @var    string
+	 *
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $defaultAdapterName = null;
+
+	/**
+	 * Returns an array with the adapter name as key and the path of the file.
+	 *
+	 * @return  array
+	 *
+	 * @throws  \Exception
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function resolveAdapterAndPath(String $path): array
+	{
+		$result = [];
+		$parts = explode(':', $path, 2);
+
+		// If we have 2 parts, we have both an adapter name and a file path
+		if (\count($parts) == 2)
+		{
+			$result['adapter'] = $parts[0];
+			$result['path']    = $parts[1];
+
+			return $result;
+		}
+
+		if (!$this->getDefaultAdapterName())
+		{
+			throw new \InvalidArgumentException('No adapter found');
+		}
+
+		// If we have less than 2 parts, we return a default adapter name
+		$result['adapter'] = $this->getDefaultAdapterName();
+
+		// If we have 1 part, we return it as the path. Otherwise we return a default path
+		$result['path'] = \count($parts) ? $parts[0] : '/';
+
+		return $result;
+	}
+
+	/**
+	 * Returns a provider for the given id.
+	 *
+	 * @return  ProviderInterface
+	 *
+	 * @throws  \Exception
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function getProvider(String $id): ProviderInterface
+	{
+		return $this->getProviderManager()->getProvider($id);
+	}
+
+	/**
+	 * Return an adapter for the given name.
+	 *
+	 * @return  AdapterInterface
+	 *
+	 * @throws  \Exception
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function getAdapter(String $name): AdapterInterface
+	{
+		return $this->getProviderManager()->getAdapter($name);
+	}
+
+	/**
+	 * Returns the default adapter name.
+	 *
+	 * @return  string|null
+	 *
+	 * @throws  \Exception
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function getDefaultAdapterName(): ?string
+	{
+		if ($this->defaultAdapterName)
+		{
+			return $this->defaultAdapterName;
+		}
+
+		$defaultAdapter = $this->getAdapter('local-' . ComponentHelper::getParams('com_media')->get('file_path', 'images'));
+
+		if (!$defaultAdapter
+			&& $this->getProviderManager()->getProvider('local')
+			&& $this->getProviderManager()->getProvider('local')->getAdapters())
+		{
+			$defaultAdapter = $this->getProviderManager()->getProvider('local')->getAdapters()[0];
+		}
+
+		if (!$defaultAdapter)
+		{
+			return null;
+		}
+
+		$this->defaultAdapterName = 'local-' . $defaultAdapter->getAdapterName();
+
+		return $this->defaultAdapterName;
+	}
+
+	/**
+	 * Return a provider manager.
+	 *
+	 * @return  ProviderManager
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function getProviderManager(): ProviderManager
+	{
+		if (!$this->providerManager)
+		{
+			$this->providerManager = new ProviderManager;
+
+			// Fire the event to get the results
+			$eventParameters = ['context' => 'AdapterManager', 'providerManager' => $this->providerManager];
+			$event           = new MediaProviderEvent('onSetupProviders', $eventParameters);
+			PluginHelper::importPlugin('filesystem');
+			Factory::getApplication()->triggerEvent('onSetupProviders', $event);
+		}
+
+		return $this->providerManager;
+	}
+}
diff --git a/api/components/com_media/src/Model/AdapterModel.php b/api/components/com_media/src/Model/AdapterModel.php
new file mode 100644
index 000000000000..381306c9bb63
--- /dev/null
+++ b/api/components/com_media/src/Model/AdapterModel.php
@@ -0,0 +1,53 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Model;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\MVC\Model\BaseModel;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service model supporting a single adapter item.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class AdapterModel extends BaseModel
+{
+	use AdapterTrait;
+
+	/**
+	 * Method to get a single adapter.
+	 *
+	 * @return  \stdClass  The adapter.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getItem(): \stdClass
+	{
+		list($provider, $account) = array_pad(explode('-', $this->getState('id'), 2), 2, null);
+
+		if ($account === null)
+		{
+			throw new \Exception('Account was not set');
+		}
+
+		$provider = $this->getProvider($provider);
+		$adapter  = $this->getAdapter($this->getState('id'));
+
+		$obj              = new \stdClass();
+		$obj->id          = $provider->getID() . '-' . $adapter->getAdapterName();
+		$obj->provider_id = $provider->getID();
+		$obj->name        = $adapter->getAdapterName();
+		$obj->path        = $provider->getID() . '-' . $adapter->getAdapterName() . ':/';
+
+		return $obj;
+	}
+}
diff --git a/api/components/com_media/src/Model/AdaptersModel.php b/api/components/com_media/src/Model/AdaptersModel.php
new file mode 100644
index 000000000000..351b79ee9aba
--- /dev/null
+++ b/api/components/com_media/src/Model/AdaptersModel.php
@@ -0,0 +1,104 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Model;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\MVC\Model\BaseModel;
+use Joomla\CMS\MVC\Model\ListModelInterface;
+use Joomla\CMS\Pagination\Pagination;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service model supporting lists of media adapters.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class AdaptersModel extends BaseModel implements ListModelInterface
+{
+	use AdapterTrait;
+
+	/**
+	 * A hacky way to enable the standard jsonapiView::displayList() to create a Pagination object,
+	 * since com_media's ApiModel does not support pagination as we know from regular ListModel derived models.
+	 *
+	 * @var    int
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $total = 0;
+
+	/**
+	 * Method to get a list of files and/or folders.
+	 *
+	 * @return  array  An array of data items.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getItems(): array
+	{
+		$adapters = [];
+		foreach ($this->getProviderManager()->getProviders() as $provider)
+		{
+			foreach ($provider->getAdapters() as $adapter)
+			{
+				$obj              = new \stdClass();
+				$obj->id          = $provider->getID() . '-' . $adapter->getAdapterName();
+				$obj->provider_id = $provider->getID();
+				$obj->name        = $adapter->getAdapterName();
+				$obj->path        = $provider->getID() . '-' . $adapter->getAdapterName() . ':/';
+
+				$adapters[] = $obj;
+			}
+		}
+
+		// A hacky way to enable the standard jsonapiView::displayList() to create a Pagination object.
+		$this->total = \count($adapters);
+
+		return $adapters;
+	}
+
+	/**
+	 * Method to get a \JPagination object for the data set.
+	 *
+	 * @return  Pagination  A Pagination object for the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getPagination(): Pagination
+	{
+		return new Pagination($this->getTotal(), $this->getStart(), 0);
+	}
+
+	/**
+	 * Method to get the starting number of items for the data set. Because com_media's ApiModel
+	 * does not support pagination as we know from regular ListModel derived models,
+	 * we always start at the top.
+	 *
+	 * @return  integer  The starting number of items available in the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getStart(): int
+	{
+		return 0;
+	}
+
+	/**
+	 * Method to get the total number of items for the data set.
+	 *
+	 * @return  integer  The total number of items available in the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getTotal(): int
+	{
+		return $this->total;
+	}
+}
diff --git a/api/components/com_media/src/Model/MediaModel.php b/api/components/com_media/src/Model/MediaModel.php
new file mode 100644
index 000000000000..572ec19e3e16
--- /dev/null
+++ b/api/components/com_media/src/Model/MediaModel.php
@@ -0,0 +1,134 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Model;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\Controller\Exception\ResourceNotFound;
+use Joomla\CMS\MVC\Model\BaseModel;
+use Joomla\CMS\MVC\Model\ListModelInterface;
+use Joomla\CMS\Pagination\Pagination;
+use Joomla\Component\Media\Administrator\Exception\FileNotFoundException;
+use Joomla\Component\Media\Administrator\Model\ApiModel;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service model supporting lists of media items.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class MediaModel extends BaseModel implements ListModelInterface
+{
+	use AdapterTrait;
+
+	/**
+	 * Instance of com_media's ApiModel
+	 *
+	 * @var ApiModel
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $mediaApiModel;
+
+	/**
+	 * A hacky way to enable the standard jsonapiView::displayList() to create a Pagination object,
+	 * since com_media's ApiModel does not support pagination as we know from regular ListModel derived models.
+	 *
+	 * @var int
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $total = 0;
+
+	public function __construct($config = [])
+	{
+		parent::__construct($config);
+
+		$this->mediaApiModel = new ApiModel();
+	}
+
+	/**
+	 * Method to get a list of files and/or folders.
+	 *
+	 * @return  array  An array of data items.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getItems(): array
+	{
+		// Map web service model state to com_media options.
+		$options = [
+			'url'       => $this->getState('url', false),
+			'temp'      => $this->getState('temp', false),
+			'search'    => $this->getState('search', ''),
+			'recursive' => $this->getState('search_recursive', false),
+			'content'   => $this->getState('content', false),
+		];
+
+		['adapter' => $adapterName, 'path' => $path] = $this->resolveAdapterAndPath($this->getState('path', ''));
+		try
+		{
+			$files = $this->mediaApiModel->getFiles($adapterName, $path, $options);
+		}
+		catch (FileNotFoundException $e)
+		{
+			throw new ResourceNotFound(
+				Text::sprintf('WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND', $path),
+				404
+			);
+		}
+
+		/**
+		 * A hacky way to enable the standard jsonapiView::displayList() to create a Pagination object.
+		 * Because com_media's ApiModel does not support pagination as we know from regular ListModel
+		 * derived models, we always return all retrieved items.
+		 */
+		$this->total = \count($files);
+
+		return $files;
+	}
+
+	/**
+	 * Method to get a \JPagination object for the data set.
+	 *
+	 * @return  Pagination  A Pagination object for the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getPagination(): Pagination
+	{
+		return new Pagination($this->getTotal(), $this->getStart(), 0);
+	}
+
+	/**
+	 * Method to get the starting number of items for the data set. Because com_media's ApiModel
+	 * does not support pagination as we know from regular ListModel derived models,
+	 * we always start at the top.
+	 *
+	 * @return  int  The starting number of items available in the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getStart(): int
+	{
+		return 0;
+	}
+
+	/**
+	 * Method to get the total number of items for the data set.
+	 *
+	 * @return  int  The total number of items available in the data set.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getTotal(): int
+	{
+		return $this->total;
+	}
+}
diff --git a/api/components/com_media/src/Model/MediumModel.php b/api/components/com_media/src/Model/MediumModel.php
new file mode 100644
index 000000000000..9768526274bf
--- /dev/null
+++ b/api/components/com_media/src/Model/MediumModel.php
@@ -0,0 +1,271 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\Model;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\Language\Text;
+use Joomla\CMS\MVC\Controller\Exception\ResourceNotFound;
+use Joomla\CMS\MVC\Controller\Exception\Save;
+use Joomla\CMS\MVC\Model\BaseModel;
+use Joomla\Component\Media\Administrator\Exception\FileExistsException;
+use Joomla\Component\Media\Administrator\Exception\FileNotFoundException;
+use Joomla\Component\Media\Administrator\Exception\InvalidPathException;
+use Joomla\Component\Media\Administrator\Model\ApiModel;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service model supporting a single media item.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class MediumModel extends BaseModel
+{
+	use AdapterTrait;
+
+	/**
+	 * Instance of com_media's ApiModel
+	 *
+	 * @var ApiModel
+	 * @since  __DEPLOY_VERSION__
+	 */
+	private $mediaApiModel;
+
+	public function __construct($config = [])
+	{
+		parent::__construct($config);
+
+		$this->mediaApiModel = new ApiModel();
+	}
+
+	/**
+	 * Method to get a single files or folder.
+	 *
+	 * @return  \stdClass  A file or folder object.
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 * @throws  ResourceNotFound
+	 */
+	public function getItem()
+	{
+		$options = [
+			'path'    => $this->getState('path', ''),
+			'url'     => $this->getState('url', false),
+			'temp'    => $this->getState('temp', false),
+			'content' => $this->getState('content', false),
+		];
+
+		['adapter' => $adapterName, 'path' => $path] = $this->resolveAdapterAndPath($this->getState('path', ''));
+
+		try
+		{
+			return $this->mediaApiModel->getFile($adapterName, $path, $options);
+		}
+		catch (FileNotFoundException $e)
+		{
+			throw new ResourceNotFound(
+				Text::sprintf('WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND', $path),
+				404
+			);
+		}
+	}
+
+	/**
+	 * Method to save a file or folder.
+	 *
+	 * @param   string  $path  The primary key of the item (if exists)
+	 *
+	 * @return  string   The path
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws  Save
+	 */
+	public function save($path = null): string
+	{
+		$path     = $this->getState('path', '');
+		$oldPath  = $this->getState('old_path', '');
+		$content  = $this->getState('content', null);
+		$override = $this->getState('override', false);
+
+		['adapter' => $adapterName, 'path' => $path] = $this->resolveAdapterAndPath($path);
+
+		$resultPath = '';
+
+		/**
+		 * If we have a (new) path and an old path, we want to move an existing
+		 * file or folder. This must be done before updating the content of a file,
+		 * if also requested (see below).
+		 */
+		if ($path && $oldPath)
+		{
+			try
+			{
+				// ApiModel::move() (or actually LocalAdapter::move()) returns a path with leading slash.
+				$resultPath = trim(
+					$this->mediaApiModel->move($adapterName, $oldPath, $path, $override),
+					'/'
+				);
+			}
+			catch (FileNotFoundException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND',
+						$oldPath
+					),
+					404
+				);
+			}
+		}
+
+		// If we have a (new) path but no old path, we want to create a
+		// new file or folder.
+		if ($path && !$oldPath)
+		{
+			// com_media expects separate directory and file name.
+			// If we moved the file before, we must use the new path.
+			$basename = basename($resultPath ?: $path);
+			$dirname  = dirname($resultPath ?: $path);
+
+			try
+			{
+				// If there is content, com_media's assumes the new item is a file.
+				// Otherwise a folder is assumed.
+				$name = $content
+					? $this->mediaApiModel->createFile(
+						$adapterName,
+						$basename,
+						$dirname,
+						$content,
+						$override
+					)
+					: $this->mediaApiModel->createFolder(
+						$adapterName,
+						$basename,
+						$dirname,
+						$override
+					);
+
+				$resultPath = $dirname . '/' . $name;
+			}
+			catch (FileNotFoundException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND',
+						$dirname . '/' . $basename
+					),
+					404
+				);
+			}
+			catch (FileExistsException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_FILE_EXISTS',
+						$dirname . '/' . $basename
+					),
+					400
+				);
+			}
+			catch (InvalidPathException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_BAD_FILE_TYPE',
+						$dirname . '/' . $basename
+					),
+					400
+				);
+			}
+		}
+
+		// If we have no (new) path but we do have an old path and we have content,
+		// we want to update the contents of an existing file.
+		if ($oldPath && $content)
+		{
+			// com_media expects separate directory and file name.
+			// If we moved the file before, we must use the new path.
+			$basename = basename($resultPath ?: $oldPath);
+			$dirname  = dirname($resultPath ?: $oldPath);
+
+			try
+			{
+				$this->mediaApiModel->updateFile(
+					$adapterName,
+					$basename,
+					$dirname,
+					$content
+				);
+			}
+			catch (FileNotFoundException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND',
+						$dirname . '/' . $basename
+					),
+					404
+				);
+			}
+			catch (InvalidPathException $e)
+			{
+				throw new Save(
+					Text::sprintf(
+						'WEBSERVICE_COM_MEDIA_BAD_FILE_TYPE',
+						$dirname . '/' . $basename
+					),
+					400
+				);
+			}
+
+			$resultPath = $resultPath ?: $oldPath;
+		}
+
+		// If we still have no result path, something fishy is going on.
+		if (!$resultPath)
+		{
+			throw new Save(
+				Text::_(
+					'WEBSERVICE_COM_MEDIA_UNSUPPORTED_PARAMETER_COMBINATION'
+				),
+				400
+			);
+		}
+
+		return $resultPath;
+	}
+
+	/**
+	 * Method to delete an existing file or folder.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 * @throws  Save
+	 */
+	public function delete(): void
+	{
+		['adapter' => $adapterName, 'path' => $path] = $this->resolveAdapterAndPath($this->getState('path', ''));
+
+		try
+		{
+			$this->mediaApiModel->delete($adapterName, $path);
+		}
+		catch (FileNotFoundException $e)
+		{
+			throw new Save(
+				Text::sprintf('WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND', $path),
+				404
+			);
+		}
+	}
+}
diff --git a/api/components/com_media/src/View/Adapters/JsonapiView.php b/api/components/com_media/src/View/Adapters/JsonapiView.php
new file mode 100644
index 000000000000..7a2d05b8b211
--- /dev/null
+++ b/api/components/com_media/src/View/Adapters/JsonapiView.php
@@ -0,0 +1,49 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\View\Adapters;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service view
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class JsonapiView extends BaseApiView
+{
+	use AdapterTrait;
+
+	/**
+	 * The fields to render item in the documents
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $fieldsToRenderItem = [
+		'provider_id',
+		'name',
+		'path',
+	];
+
+	/**
+	 * The fields to render items in the documents
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $fieldsToRenderList = [
+		'provider_id',
+		'name',
+		'path',
+	];
+}
diff --git a/api/components/com_media/src/View/Media/JsonapiView.php b/api/components/com_media/src/View/Media/JsonapiView.php
new file mode 100644
index 000000000000..84ee67bf32ed
--- /dev/null
+++ b/api/components/com_media/src/View/Media/JsonapiView.php
@@ -0,0 +1,95 @@
+<?php
+/**
+ * @package     Joomla.API
+ * @subpackage  com_media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+namespace Joomla\Component\Media\Api\View\Media;
+
+\defined('_JEXEC') or die;
+
+use Joomla\CMS\MVC\View\JsonApiView as BaseApiView;
+use Joomla\Component\Media\Administrator\Provider\ProviderManager;
+use Joomla\Component\Media\Api\Helper\AdapterTrait;
+
+/**
+ * Media web service view
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class JsonapiView extends BaseApiView
+{
+	use AdapterTrait;
+
+	/**
+	 * The fields to render item in the documents
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $fieldsToRenderItem = [
+		'type',
+		'name',
+		'path',
+		'extension',
+		'size',
+		'mime_type',
+		'width',
+		'height',
+		'create_date',
+		'create_date_formatted',
+		'modified_date',
+		'modified_date_formatted',
+		'thumb_path',
+		'adapter',
+		'content',
+		'url',
+		'tempUrl',
+	];
+
+	/**
+	 * The fields to render items in the documents
+	 *
+	 * @var    array
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $fieldsToRenderList = [
+		'type',
+		'name',
+		'path',
+		'extension',
+		'size',
+		'mime_type',
+		'width',
+		'height',
+		'create_date',
+		'create_date_formatted',
+		'modified_date',
+		'modified_date_formatted',
+		'thumb_path',
+		'adapter',
+		'content',
+		'url',
+		'tempUrl',
+	];
+
+	/**
+	 * Prepare item before render.
+	 *
+	 * @param   object  $item  The model item
+	 *
+	 * @return  object
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	protected function prepareItem($item)
+	{
+		// Media resources have no id.
+		$item->id = '0';
+
+		return $item;
+	}
+}
diff --git a/api/language/en-GB/com_media.ini b/api/language/en-GB/com_media.ini
new file mode 100644
index 000000000000..3e36202c201d
--- /dev/null
+++ b/api/language/en-GB/com_media.ini
@@ -0,0 +1,11 @@
+; Joomla! Project
+; (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+; License GNU General Public License version 2 or later; see LICENSE.txt
+; Note : All ini files need to be saved as UTF-8
+
+WEBSERVICE_COM_MEDIA="Media web service"
+WEBSERVICE_COM_MEDIA_MISSING_REQUIRED_PARAMETERS="Missing required parameter(s): %s"
+WEBSERVICE_COM_MEDIA_FILE_NOT_FOUND="File not found: %s"
+WEBSERVICE_COM_MEDIA_FILE_EXISTS="File exists and overwriting not requested: %s"
+WEBSERVICE_COM_MEDIA_BAD_FILE_TYPE="Invalid path or file type not allowed: %s"
+WEBSERVICE_COM_MEDIA_UNSUPPORTED_PARAMETER_COMBINATION="Unexpected or unsupported query parameter combination"
diff --git a/composer.json b/composer.json
index 75a1266667df..c760f66ef3f7 100644
--- a/composer.json
+++ b/composer.json
@@ -99,7 +99,8 @@
         "codeception/module-db": "^1.0",
         "codeception/module-rest": "^1.0",
         "codeception/module-webdriver": "^1.0",
-        "codeception/module-phpbrowser": "^1.0"
+        "codeception/module-phpbrowser": "^1.0",
+        "hoa/console": "^3.17"
     },
     "replace": {
         "paragonie/random_compat": "9.99.99"
diff --git a/composer.lock b/composer.lock
index ed849b302350..df658ab10845 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "1da875478fc037b5b7b0c997043f2416",
+    "content-hash": "7a38a492e1140d3acdd45a4fb7f42486",
     "packages": [
         {
             "name": "algo26-matthias/idna-convert",
@@ -6725,6 +6725,636 @@
             ],
             "time": "2021-10-06T17:43:30+00:00"
         },
+        {
+            "name": "hoa/consistency",
+            "version": "1.17.05.02",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Consistency.git",
+                "reference": "fd7d0adc82410507f332516faf655b6ed22e4c2f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Consistency/zipball/fd7d0adc82410507f332516faf655b6ed22e4c2f",
+                "reference": "fd7d0adc82410507f332516faf655b6ed22e4c2f",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/exception": "~1.0",
+                "php": ">=5.5.0"
+            },
+            "require-dev": {
+                "hoa/stream": "~1.0",
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Consistency\\": "."
+                },
+                "files": [
+                    "Prelude.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Consistency library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "autoloader",
+                "callable",
+                "consistency",
+                "entity",
+                "flex",
+                "keyword",
+                "library"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Consistency",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Consistency/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Consistency"
+            },
+            "abandoned": true,
+            "time": "2017-05-02T12:18:12+00:00"
+        },
+        {
+            "name": "hoa/console",
+            "version": "3.17.05.02",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Console.git",
+                "reference": "e231fd3ea70e6d773576ae78de0bdc1daf331a66"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Console/zipball/e231fd3ea70e6d773576ae78de0bdc1daf331a66",
+                "reference": "e231fd3ea70e6d773576ae78de0bdc1daf331a66",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/event": "~1.0",
+                "hoa/exception": "~1.0",
+                "hoa/file": "~1.0",
+                "hoa/protocol": "~1.0",
+                "hoa/stream": "~1.0",
+                "hoa/ustring": "~4.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "suggest": {
+                "ext-pcntl": "To enable hoa://Event/Console/Window:resize.",
+                "hoa/dispatcher": "To use the console kit.",
+                "hoa/router": "To use the console kit."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "3.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Console\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Console library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "autocompletion",
+                "chrome",
+                "cli",
+                "console",
+                "cursor",
+                "getoption",
+                "library",
+                "option",
+                "parser",
+                "processus",
+                "readline",
+                "terminfo",
+                "tput",
+                "window"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Console",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Console/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Console"
+            },
+            "abandoned": true,
+            "time": "2017-05-02T12:26:19+00:00"
+        },
+        {
+            "name": "hoa/event",
+            "version": "1.17.01.13",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Event.git",
+                "reference": "6c0060dced212ffa3af0e34bb46624f990b29c54"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Event/zipball/6c0060dced212ffa3af0e34bb46624f990b29c54",
+                "reference": "6c0060dced212ffa3af0e34bb46624f990b29c54",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/exception": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Event\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Event library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "event",
+                "library",
+                "listener",
+                "observer"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Event",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Event/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Event"
+            },
+            "abandoned": true,
+            "time": "2017-01-13T15:30:50+00:00"
+        },
+        {
+            "name": "hoa/exception",
+            "version": "1.17.01.16",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Exception.git",
+                "reference": "091727d46420a3d7468ef0595651488bfc3a458f"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Exception/zipball/091727d46420a3d7468ef0595651488bfc3a458f",
+                "reference": "091727d46420a3d7468ef0595651488bfc3a458f",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/event": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Exception\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Exception library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "exception",
+                "library"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Exception",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Exception/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Exception"
+            },
+            "abandoned": true,
+            "time": "2017-01-16T07:53:27+00:00"
+        },
+        {
+            "name": "hoa/file",
+            "version": "1.17.07.11",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/File.git",
+                "reference": "35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/File/zipball/35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca",
+                "reference": "35cb979b779bc54918d2f9a4e02ed6c7a1fa67ca",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/event": "~1.0",
+                "hoa/exception": "~1.0",
+                "hoa/iterator": "~2.0",
+                "hoa/stream": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\File\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\File library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "Socket",
+                "directory",
+                "file",
+                "finder",
+                "library",
+                "link",
+                "temporary"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/File",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/File/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/File"
+            },
+            "abandoned": true,
+            "time": "2017-07-11T07:42:15+00:00"
+        },
+        {
+            "name": "hoa/iterator",
+            "version": "2.17.01.10",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Iterator.git",
+                "reference": "d1120ba09cb4ccd049c86d10058ab94af245f0cc"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Iterator/zipball/d1120ba09cb4ccd049c86d10058ab94af245f0cc",
+                "reference": "d1120ba09cb4ccd049c86d10058ab94af245f0cc",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/exception": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "2.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Iterator\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Iterator library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "iterator",
+                "library"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Iterator",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Iterator/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Iterator"
+            },
+            "abandoned": true,
+            "time": "2017-01-10T10:34:47+00:00"
+        },
+        {
+            "name": "hoa/protocol",
+            "version": "1.17.01.14",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Protocol.git",
+                "reference": "5c2cf972151c45f373230da170ea015deecf19e2"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Protocol/zipball/5c2cf972151c45f373230da170ea015deecf19e2",
+                "reference": "5c2cf972151c45f373230da170ea015deecf19e2",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/exception": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Protocol\\": "."
+                },
+                "files": [
+                    "Wrapper.php"
+                ]
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Protocol library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "library",
+                "protocol",
+                "resource",
+                "stream",
+                "wrapper"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Protocol",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Protocol/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Protocol"
+            },
+            "abandoned": true,
+            "time": "2017-01-14T12:26:10+00:00"
+        },
+        {
+            "name": "hoa/stream",
+            "version": "1.17.02.21",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Stream.git",
+                "reference": "3293cfffca2de10525df51436adf88a559151d82"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Stream/zipball/3293cfffca2de10525df51436adf88a559151d82",
+                "reference": "3293cfffca2de10525df51436adf88a559151d82",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/event": "~1.0",
+                "hoa/exception": "~1.0",
+                "hoa/protocol": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "1.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Stream\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Stream library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "Context",
+                "bucket",
+                "composite",
+                "filter",
+                "in",
+                "library",
+                "out",
+                "protocol",
+                "stream",
+                "wrapper"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Stream",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Stream/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Stream"
+            },
+            "abandoned": true,
+            "time": "2017-02-21T16:01:06+00:00"
+        },
+        {
+            "name": "hoa/ustring",
+            "version": "4.17.01.16",
+            "source": {
+                "type": "git",
+                "url": "https://github.com/hoaproject/Ustring.git",
+                "reference": "e6326e2739178799b1fe3fdd92029f9517fa17a0"
+            },
+            "dist": {
+                "type": "zip",
+                "url": "https://api.github.com/repos/hoaproject/Ustring/zipball/e6326e2739178799b1fe3fdd92029f9517fa17a0",
+                "reference": "e6326e2739178799b1fe3fdd92029f9517fa17a0",
+                "shasum": ""
+            },
+            "require": {
+                "hoa/consistency": "~1.0",
+                "hoa/exception": "~1.0"
+            },
+            "require-dev": {
+                "hoa/test": "~2.0"
+            },
+            "suggest": {
+                "ext-iconv": "ext/iconv must be present (or a third implementation) to use Hoa\\Ustring::transcode().",
+                "ext-intl": "To get a better Hoa\\Ustring::toAscii() and Hoa\\Ustring::compareTo()."
+            },
+            "type": "library",
+            "extra": {
+                "branch-alias": {
+                    "dev-master": "4.x-dev"
+                }
+            },
+            "autoload": {
+                "psr-4": {
+                    "Hoa\\Ustring\\": "."
+                }
+            },
+            "notification-url": "https://packagist.org/downloads/",
+            "license": [
+                "BSD-3-Clause"
+            ],
+            "authors": [
+                {
+                    "name": "Ivan Enderlin",
+                    "email": "[email protected]"
+                },
+                {
+                    "name": "Hoa community",
+                    "homepage": "https://hoa-project.net/"
+                }
+            ],
+            "description": "The Hoa\\Ustring library.",
+            "homepage": "https://hoa-project.net/",
+            "keywords": [
+                "library",
+                "search",
+                "string",
+                "unicode"
+            ],
+            "support": {
+                "docs": "https://central.hoa-project.net/Documentation/Library/Ustring",
+                "email": "[email protected]",
+                "forum": "https://users.hoa-project.net/",
+                "irc": "irc://chat.freenode.net/hoaproject",
+                "issues": "https://github.com/hoaproject/Ustring/issues",
+                "source": "https://central.hoa-project.net/Resource/Library/Ustring"
+            },
+            "abandoned": true,
+            "time": "2017-01-16T07:08:25+00:00"
+        },
         {
             "name": "joomla-projects/joomla-browser",
             "version": "v4.0.0.x-dev",
diff --git a/installation/sql/mysql/base.sql b/installation/sql/mysql/base.sql
index d7a5ea81915d..82fd5d03509e 100644
--- a/installation/sql/mysql/base.sql
+++ b/installation/sql/mysql/base.sql
@@ -353,6 +353,7 @@ INSERT INTO `#__extensions` (`package_id`, `name`, `type`, `element`, `folder`,
 (0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 4, 0),
 (0, 'plg_webservices_installer', 'plugin', 'installer', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 5, 0),
 (0, 'plg_webservices_languages', 'plugin', 'languages', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 6, 0),
+(0, 'plg_webservices_media', 'plugin', 'media', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
 (0, 'plg_webservices_menus', 'plugin', 'menus', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
 (0, 'plg_webservices_messages', 'plugin', 'messages', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 8, 0),
 (0, 'plg_webservices_modules', 'plugin', 'modules', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 9, 0),
diff --git a/installation/sql/postgresql/base.sql b/installation/sql/postgresql/base.sql
index 55d319d745aa..8786f98bd20e 100644
--- a/installation/sql/postgresql/base.sql
+++ b/installation/sql/postgresql/base.sql
@@ -359,6 +359,7 @@ INSERT INTO "#__extensions" ("package_id", "name", "type", "element", "folder",
 (0, 'plg_webservices_content', 'plugin', 'content', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 4, 0),
 (0, 'plg_webservices_installer', 'plugin', 'installer', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 5, 0),
 (0, 'plg_webservices_languages', 'plugin', 'languages', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 6, 0),
+(0, 'plg_webservices_media', 'plugin', 'media', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
 (0, 'plg_webservices_menus', 'plugin', 'menus', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 7, 0),
 (0, 'plg_webservices_messages', 'plugin', 'messages', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 8, 0),
 (0, 'plg_webservices_modules', 'plugin', 'modules', 'webservices', 0, 1, 1, 0, 1, '', '{}', '', 9, 0),
diff --git a/libraries/src/Error/JsonApi/SaveExceptionHandler.php b/libraries/src/Error/JsonApi/SaveExceptionHandler.php
index fe76941e55e2..a8ae0a15dffe 100644
--- a/libraries/src/Error/JsonApi/SaveExceptionHandler.php
+++ b/libraries/src/Error/JsonApi/SaveExceptionHandler.php
@@ -53,7 +53,10 @@ public function handle(Exception $e)
 			$status = $e->getCode();
 		}
 
-		$error = ['title' => $e->getMessage()];
+		$error = [
+			'title' => $e->getMessage(),
+			'code' => $status,
+		];
 
 		return new ResponseBag($status, [$error]);
 	}
diff --git a/plugins/webservices/media/media.php b/plugins/webservices/media/media.php
new file mode 100644
index 000000000000..cde9ffe1c45e
--- /dev/null
+++ b/plugins/webservices/media/media.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * @package     Joomla.Plugin
+ * @subpackage  Webservices.Media
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+defined('_JEXEC') or die;
+
+use Joomla\CMS\Plugin\CMSPlugin;
+use Joomla\CMS\Router\ApiRouter;
+use Joomla\Router\Route;
+
+/**
+ * Web Services adapter for com_media.
+ *
+ * @since  __DEPLOY_VERSION__
+ */
+class PlgWebservicesMedia extends CMSPlugin
+{
+	/**
+	 * Load the language file on instantiation.
+	 *
+	 * @var    boolean
+	 * @since  __DEPLOY_VERSION__
+	 */
+	protected $autoloadLanguage = true;
+
+	/**
+	 * Registers com_media's API's routes in the application.
+	 *
+	 * @param   ApiRouter  &$router  The API Routing object
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function onBeforeApiRoute(&$router): void
+	{
+		$this->createAdapterReadRoutes(
+			$router,
+			'v1/media/adapters',
+			'adapters',
+			['component' => 'com_media']
+		);
+		$this->createMediaCRUDRoutes(
+			$router,
+			'v1/media/files',
+			'media',
+			['component' => 'com_media']
+		);
+	}
+
+	/**
+	 * Creates adapter read routes.
+	 *
+	 * @param   ApiRouter  &$router     The API Routing object
+	 * @param   string     $baseName    The base name of the component.
+	 * @param   string     $controller  The name of the controller that contains CRUD functions.
+	 * @param   array      $defaults    An array of default values that are used when the URL is matched.
+	 * @param   bool       $publicGets  Allow the public to make GET requests.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function createAdapterReadRoutes(&$router, $baseName, $controller, $defaults = [], $publicGets = false): void
+	{
+		$getDefaults = array_merge(['public' => $publicGets], $defaults);
+
+		$routes = [
+			new Route(['GET'], $baseName, $controller . '.displayList', [], $getDefaults),
+			new Route(['GET'], $baseName . '/:id', $controller . '.displayItem', [], $getDefaults),
+		];
+
+		$router->addRoutes($routes);
+	}
+
+	/**
+	 * Creates media CRUD routes.
+	 *
+	 * @param   ApiRouter  &$router     The API Routing object
+	 * @param   string     $baseName    The base name of the component.
+	 * @param   string     $controller  The name of the controller that contains CRUD functions.
+	 * @param   array      $defaults    An array of default values that are used when the URL is matched.
+	 * @param   bool       $publicGets  Allow the public to make GET requests.
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function createMediaCRUDRoutes(&$router, $baseName, $controller, $defaults = [], $publicGets = false): void
+	{
+		$getDefaults = array_merge(['public' => $publicGets], $defaults);
+
+		$routes = [
+			new Route(['GET'], $baseName, $controller . '.displayList', [], $getDefaults),
+			// When the path ends with a backslash, then list the items
+			new Route(['GET'], $baseName . '/:path/', $controller . '.displayList', ['path' => '.*\/'], $getDefaults),
+			new Route(['GET'], $baseName . '/:path', $controller . '.displayItem', ['path' => '.*'], $getDefaults),
+			new Route(['POST'], $baseName, $controller . '.add', [], $defaults),
+			new Route(['PATCH'], $baseName . '/:path', $controller . '.edit', ['path' => '.*'], $defaults),
+			new Route(['DELETE'], $baseName . '/:path', $controller . '.delete', ['path' => '.*'], $defaults),
+		];
+
+		$router->addRoutes($routes);
+	}
+}
diff --git a/plugins/webservices/media/media.xml b/plugins/webservices/media/media.xml
new file mode 100644
index 000000000000..95574782634d
--- /dev/null
+++ b/plugins/webservices/media/media.xml
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<extension type="plugin" group="webservices" method="upgrade">
+	<name>plg_webservices_media</name>
+	<author>Joomla! Project</author>
+	<creationDate>May 2021</creationDate>
+	<copyright>(C) 2021 Open Source Matters, Inc.</copyright>
+	<license>GNU General Public License version 2 or later; see LICENSE.txt</license>
+	<authorEmail>[email protected]</authorEmail>
+	<authorUrl>www.joomla.org</authorUrl>
+	<version>__DEPLOY_VERSION__</version>
+	<description>PLG_WEBSERVICES_MEDIA_XML_DESCRIPTION</description>
+	<files>
+		<filename plugin="media">media.php</filename>
+	</files>
+	<languages>
+		<language tag="en-GB">language/en-GB/en-GB.plg_webservices_media.ini</language>
+		<language tag="en-GB">language/en-GB/en-GB.plg_webservices_media.sys.ini</language>
+	</languages>
+</extension>
diff --git a/tests/Codeception/_support/Helper/Api.php b/tests/Codeception/_support/Helper/Api.php
index c0c4e97747a7..cf6fd685e4b3 100644
--- a/tests/Codeception/_support/Helper/Api.php
+++ b/tests/Codeception/_support/Helper/Api.php
@@ -21,4 +21,58 @@
  */
 class Api extends Module
 {
+	/**
+	 * Creates a user for API authentication and returns a bearer token.
+	 *
+	 * @return  string  The token
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getBearerToken(): string
+	{
+		/** @var JoomlaDb $db */
+		$db = $this->getModule('Helper\\JoomlaDb');
+
+		$desiredUserId = 3;
+
+		if (!$db->grabFromDatabase('users', 'id', ['id' => $desiredUserId]))
+		{
+			$db->haveInDatabase(
+				'users',
+				[
+					'id'           => $desiredUserId,
+					'name'         => 'API',
+					'email'        => '[email protected]',
+					'username'     => 'api',
+					'password'     => '123',
+					'block'        => 0,
+					'registerDate' => '2000-01-01',
+					'params'       => '{}'
+				],
+				[]
+			);
+			$db->haveInDatabase('user_usergroup_map', ['user_id' => $desiredUserId, 'group_id' => 8]);
+			$enabledData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.enabled', 'profile_value' => 1];
+			$tokenData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.token', 'profile_value' => 'dOi2m1NRrnBHlhaWK/WWxh3B5tqq1INbdf4DhUmYTI4='];
+			$db->haveInDatabase('user_profiles', $enabledData);
+			$db->haveInDatabase('user_profiles', $tokenData);
+		}
+
+		return 'c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==';
+	}
+
+	/**
+	 * Creates a user for API authentication and returns a bearer token.
+	 *
+	 * @param   string  $name     The name of the config key
+	 * @param   string  $module   The module
+	 *
+	 * @return  string  The config key
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function getConfig($name, $module = 'Helper\Api'): string
+	{
+		return $this->getModule($module)->_getConfig()[$name];
+	}
 }
diff --git a/tests/Codeception/_support/Helper/JoomlaDb.php b/tests/Codeception/_support/Helper/JoomlaDb.php
index d508c65704a6..d3a892b6eda7 100644
--- a/tests/Codeception/_support/Helper/JoomlaDb.php
+++ b/tests/Codeception/_support/Helper/JoomlaDb.php
@@ -164,6 +164,23 @@ public function updateInDatabase($table, array $data, array $criteria = [])
 		parent::updateInDatabase($table, $data, $criteria);
 	}
 
+	/**
+	 * Deletes records in a database.
+	 *
+	 * @param   string  $table     Table name
+	 * @param   array   $criteria  Search criteria [Optional]
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function deleteFromDatabase($table, $criteria = []): void
+	{
+		$table = $this->addPrefix($table);
+
+		$this->driver->deleteQueryByCriteria($table, $criteria);
+	}
+
 	/**
 	 * Add the table prefix.
 	 *
diff --git a/tests/Codeception/acceptance/01-install/InstallCest.php b/tests/Codeception/acceptance/01-install/InstallCest.php
index d7832b7042d4..2e3ef6b32496 100644
--- a/tests/Codeception/acceptance/01-install/InstallCest.php
+++ b/tests/Codeception/acceptance/01-install/InstallCest.php
@@ -26,7 +26,7 @@ class InstallCest
 	public function installJoomla(AcceptanceTester $I)
 	{
 		$I->am('Administrator');
-		$I->installJoomlaRemovingInstallationFolder();
+		$I->installJoomla();
 	}
 
 	/**
diff --git a/tests/Codeception/api.suite.dist.yml b/tests/Codeception/api.suite.dist.yml
index 12a74716bb1e..a8f50914482e 100644
--- a/tests/Codeception/api.suite.dist.yml
+++ b/tests/Codeception/api.suite.dist.yml
@@ -2,7 +2,7 @@ actor: ApiTester
 modules:
     enabled:
         - Helper\JoomlaDb
-        - \Helper\Api
+        - Helper\Api
         - REST:
              url: http://localhost/test-install/api/index.php/v1
              depends: PhpBrowser
@@ -13,3 +13,7 @@ modules:
             user: 'root'
             password: 'joomla_ut'
             prefix: 'jos_'
+        Helper\Api:
+            url: 'http://localhost/test-install'
+            cmsPath: '/tests/www/test-install'
+            localUser: 'www-data'
diff --git a/tests/Codeception/api/BasicCest.php b/tests/Codeception/api/BasicCest.php
index 784628f2848e..8e30340b758c 100644
--- a/tests/Codeception/api/BasicCest.php
+++ b/tests/Codeception/api/BasicCest.php
@@ -17,39 +17,6 @@
  */
 class BasicCest
 {
-	/**
-	 * Api test before running.
-	 *
-	 * @param   mixed   ApiTester  $I  Api tester
-	 *
-	 * @return void
-	 *
-	 * @since   4.0.0
-	 */
-	public function _before(ApiTester $I)
-	{
-		// TODO: Improve this to retrieve a specific ID to replace with a known ID
-		$desiredUserId = 3;
-		$I->updateInDatabase('users', ['id' => 3], []);
-		$I->updateInDatabase('user_usergroup_map', ['user_id' => 3], []);
-		$enabledData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.enabled', 'profile_value' => 1];
-		$tokenData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.token', 'profile_value' => 'dOi2m1NRrnBHlhaWK/WWxh3B5tqq1INbdf4DhUmYTI4='];
-		$I->haveInDatabase('user_profiles', $enabledData);
-		$I->haveInDatabase('user_profiles', $tokenData);
-	}
-
-	/**
-	 * Api test after running.
-	 *
-	 * @param   mixed   ApiTester  $I  Api tester
-	 *
-	 * @return void
-	 * @since   4.0.0
-	 */
-	public function _after(ApiTester $I)
-	{
-	}
-
 	/**
 	 * Test logging in with wrong credentials.
 	 *
@@ -78,7 +45,7 @@ public function testWrongCredentials(ApiTester $I)
 	 */
 	public function testContentNegotiation(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'text/xml');
 		$I->sendGET('/content/articles/1');
 		$I->seeResponseCodeIs(Codeception\Util\HttpCode::NOT_ACCEPTABLE);
@@ -95,7 +62,7 @@ public function testContentNegotiation(ApiTester $I)
 	 */
 	public function testRouteNotFound(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendGET('/not/existing/1');
 		$I->seeResponseCodeIs(Codeception\Util\HttpCode::NOT_FOUND);
diff --git a/tests/Codeception/api/com_banners/BannerCest.php b/tests/Codeception/api/com_banners/BannerCest.php
index d2ac914ab6d0..3eb10f39fb6e 100644
--- a/tests/Codeception/api/com_banners/BannerCest.php
+++ b/tests/Codeception/api/com_banners/BannerCest.php
@@ -29,27 +29,8 @@ class BannerCest
 	 */
 	public function _before(ApiTester $I)
 	{
-		// TODO: Improve this to retrieve a specific ID to replace with a known ID
-		$desiredUserId = 3;
-		$I->updateInDatabase('users', ['id' => 3], []);
-		$I->updateInDatabase('user_usergroup_map', ['user_id' => 3], []);
-		$enabledData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.enabled', 'profile_value' => 1];
-		$tokenData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.token', 'profile_value' => 'dOi2m1NRrnBHlhaWK/WWxh3B5tqq1INbdf4DhUmYTI4='];
-		$I->haveInDatabase('user_profiles', $enabledData);
-		$I->haveInDatabase('user_profiles', $tokenData);
-	}
-
-	/**
-	 * Api test after running.
-	 *
-	 * @param   mixed   ApiTester  $I  Api tester
-	 *
-	 * @return void
-	 *
-	 * @since   4.0.0
-	 */
-	public function _after(ApiTester $I)
-	{
+		$I->deleteFromDatabase('banners');
+		$I->deleteFromDatabase('categories', ['id >' => 7]);
 	}
 
 	/**
@@ -65,7 +46,7 @@ public function _after(ApiTester $I)
 	 */
 	public function testCrudOnBanner(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -86,23 +67,24 @@ public function testCrudOnBanner(ApiTester $I)
 		$I->sendPOST('/banners', $testBanner);
 
 		$I->seeResponseCodeIs(HttpCode::OK);
+		$id = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendGET('/banners/1');
+		$I->sendGET('/banners/' . $id);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
 		// Category is a required field for this patch request for now TODO: Remove this dependency
-		$I->sendPATCH('/banners/1', ['name' => 'Different Custom Advert', 'state' => -2, 'catid' => 3]);
+		$I->sendPATCH('/banners/' . $id, ['name' => 'Different Custom Advert', 'state' => -2, 'catid' => 3]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendDELETE('/banners/1');
+		$I->sendDELETE('/banners/' . $id);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
 	}
 
@@ -119,7 +101,7 @@ public function testCrudOnBanner(ApiTester $I)
 	 */
 	public function testCrudOnCategory(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -133,12 +115,12 @@ public function testCrudOnCategory(ApiTester $I)
 		$I->seeResponseCodeIs(HttpCode::OK);
 		$categoryId = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendGET('/banners/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -146,7 +128,7 @@ public function testCrudOnCategory(ApiTester $I)
 		$I->sendPATCH('/banners/categories/' . $categoryId, ['title' => 'Another Title', 'published' => -2]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendDELETE('/banners/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
diff --git a/tests/Codeception/api/com_contact/ContactCest.php b/tests/Codeception/api/com_contact/ContactCest.php
index d2bad11bb63d..ce3f93e865d6 100644
--- a/tests/Codeception/api/com_contact/ContactCest.php
+++ b/tests/Codeception/api/com_contact/ContactCest.php
@@ -29,27 +29,8 @@ class ContactCest
 	 */
 	public function _before(ApiTester $I)
 	{
-		// TODO: Improve this to retrieve a specific ID to replace with a known ID
-		$desiredUserId = 3;
-		$I->updateInDatabase('users', ['id' => 3], []);
-		$I->updateInDatabase('user_usergroup_map', ['user_id' => 3], []);
-		$enabledData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.enabled', 'profile_value' => 1];
-		$tokenData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.token', 'profile_value' => 'dOi2m1NRrnBHlhaWK/WWxh3B5tqq1INbdf4DhUmYTI4='];
-		$I->haveInDatabase('user_profiles', $enabledData);
-		$I->haveInDatabase('user_profiles', $tokenData);
-	}
-
-	/**
-	 * Api test after running.
-	 *
-	 * @param   mixed   ApiTester  $I  Api tester
-	 *
-	 * @return void
-	 *
-	 * @since   4.0.0
-	 */
-	public function _after(ApiTester $I)
-	{
+		$I->deleteFromDatabase('contact_details');
+		$I->deleteFromDatabase('categories', ['id >' => 7]);
 	}
 
 	/**
@@ -65,7 +46,7 @@ public function _after(ApiTester $I)
 	 */
 	public function testCrudOnContact(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -79,23 +60,24 @@ public function testCrudOnContact(ApiTester $I)
 		$I->sendPOST('/contacts', $testarticle);
 
 		$I->seeResponseCodeIs(HttpCode::OK);
+		$id = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendGET('/contacts/1');
+		$I->sendGET('/contacts/' . $id);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
 		// Category is a required field for this patch request for now TODO: Remove this dependency
-		$I->sendPATCH('/contacts/1', ['name' => 'Frankie Blogs', 'catid' => 4, 'published' => -2]);
+		$I->sendPATCH('/contacts/' . $id, ['name' => 'Frankie Blogs', 'catid' => 4, 'published' => -2]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendDELETE('/contacts/1');
+		$I->sendDELETE('/contacts/' . $id);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
 	}
 
@@ -112,7 +94,7 @@ public function testCrudOnContact(ApiTester $I)
 	 */
 	public function testCrudOnCategory(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -129,18 +111,18 @@ public function testCrudOnCategory(ApiTester $I)
 		$I->seeResponseCodeIs(HttpCode::OK);
 		$categoryId = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendGET('/contacts/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendPATCH('/contacts/categories/' . $categoryId, ['title' => 'Another Title', 'published' => -2]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendDELETE('/contacts/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
diff --git a/tests/Codeception/api/com_content/ContentCest.php b/tests/Codeception/api/com_content/ContentCest.php
index 08ac412e7785..9a75b6cc35e7 100644
--- a/tests/Codeception/api/com_content/ContentCest.php
+++ b/tests/Codeception/api/com_content/ContentCest.php
@@ -29,27 +29,8 @@ class ContentCest
 	 */
 	public function _before(ApiTester $I)
 	{
-		// TODO: Improve this to retrieve a specific ID to replace with a known ID
-		$desiredUserId = 3;
-		$I->updateInDatabase('users', ['id' => 3], []);
-		$I->updateInDatabase('user_usergroup_map', ['user_id' => 3], []);
-		$enabledData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.enabled', 'profile_value' => 1];
-		$tokenData = ['user_id' => $desiredUserId, 'profile_key' => 'joomlatoken.token', 'profile_value' => 'dOi2m1NRrnBHlhaWK/WWxh3B5tqq1INbdf4DhUmYTI4='];
-		$I->haveInDatabase('user_profiles', $enabledData);
-		$I->haveInDatabase('user_profiles', $tokenData);
-	}
-
-	/**
-	 * Api test after running.
-	 *
-	 * @param   mixed   ApiTester  $I  Api tester
-	 *
-	 * @return void
-	 *
-	 * @since   4.0.0
-	 */
-	public function _after(ApiTester $I)
-	{
+		$I->deleteFromDatabase('content');
+		$I->deleteFromDatabase('categories', ['id >' => 7]);
 	}
 
 	/**
@@ -65,7 +46,7 @@ public function _after(ApiTester $I)
 	 */
 	public function testCrudOnArticle(ApiTester $I)
 	{
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -80,21 +61,22 @@ public function testCrudOnArticle(ApiTester $I)
 		$I->sendPOST('/content/articles', $testarticle);
 
 		$I->seeResponseCodeIs(HttpCode::OK);
+		$id = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendGET('/content/articles/1');
+		$I->sendGET('/content/articles/' . $id);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendPATCH('/content/articles/1', ['title' => 'Another Title', 'state' => -2, 'catid' => 2]);
+		$I->sendPATCH('/content/articles/' . $id, ['title' => 'Another Title', 'state' => -2, 'catid' => 2]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
-		$I->sendDELETE('/content/articles/1');
+		$I->sendDELETE('/content/articles/' . $id);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
 	}
 
@@ -112,7 +94,7 @@ public function testCrudOnArticle(ApiTester $I)
 	public function testCrudOnCategory(ApiTester $I)
 	{
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 
@@ -129,18 +111,18 @@ public function testCrudOnCategory(ApiTester $I)
 		$I->seeResponseCodeIs(HttpCode::OK);
 		$categoryId = $I->grabDataFromResponseByJsonPath('$.data.id')[0];
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendGET('/content/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Content-Type', 'application/json');
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendPATCH('/content/categories/' . $categoryId, ['title' => 'Another Title', 'params' => ['workflow_id' => 'inherit'], 'published' => -2]);
 		$I->seeResponseCodeIs(HttpCode::OK);
 
-		$I->amBearerAuthenticated('c2hhMjU2OjM6ZTJmMjJlYTNlNTU0NmM1MDJhYTIzYzMwN2MxYzAwZTQ5NzJhMWRmOTUyNjY5MTk2YjE5ODJmZWMwZTcxNzgwMQ==');
+		$I->amBearerAuthenticated($I->getBearerToken());
 		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
 		$I->sendDELETE('/content/categories/' . $categoryId);
 		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
diff --git a/tests/Codeception/api/com_media/MediaCest.php b/tests/Codeception/api/com_media/MediaCest.php
new file mode 100644
index 000000000000..e6f973cba5d1
--- /dev/null
+++ b/tests/Codeception/api/com_media/MediaCest.php
@@ -0,0 +1,405 @@
+<?php
+/**
+ * @package     Joomla.Tests
+ * @subpackage  Api.tests
+ *
+ * @copyright   (C) 2021 Open Source Matters, Inc. <https://www.joomla.org>
+ * @license     GNU General Public License version 2 or later; see LICENSE.txt
+ */
+
+use Codeception\Util\FileSystem;
+use Codeception\Util\HttpCode;
+
+/**
+ * Class MediaCest.
+ *
+ * Basic com_media (files) tests.
+ *
+ * @since   __DEPLOY_VERSION__
+ */
+class MediaCest
+{
+	/**
+	 * The name of the test directory, which gets deleted after each test.
+	 *
+	 * @var     string
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private $testDirectory = 'test-dir';
+
+	/**
+	 * Runs before every test.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws Exception
+	 */
+	public function _before(ApiTester $I)
+	{
+		if (file_exists($this->getImagesDirectory($I)))
+		{
+			FileSystem::deleteDir($this->getImagesDirectory($I));
+		}
+
+		// Copied from \Step\Acceptance\Administrator\Media:createDirectory()
+		$oldUmask     = @umask(0);
+		@mkdir($this->getImagesDirectory($I), 0755, true);
+
+		if (!empty($user = $I->getConfig('localUser')))
+		{
+			@chown($this->getImagesDirectory($I), $user);
+		}
+
+		@umask($oldUmask);
+	}
+
+	/**
+	 * Runs after every test.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 *
+	 * @throws Exception
+	 */
+	public function _after(ApiTester $I)
+	{
+		// Delete the test directory
+		FileSystem::deleteDir($this->getImagesDirectory($I));
+	}
+
+	/**
+	 * Test the GET media adapter endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetAdapters(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/adapters');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['provider_id' => 'local', 'name' => 'images']);
+	}
+
+	/**
+	 * Test the GET media adapter endpoint for a single adapter of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetAdapter(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/adapters/local-images');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['provider_id' => 'local', 'name' => 'images']);
+	}
+
+	/**
+	 * Test the GET media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFiles(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'banners']]]);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'joomla_black.png']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFilesInSubfolder(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files/sampledata/cassiopeia/');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'nasa1-1200.jpg']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFilesWithAdapter(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files/local-images:/sampledata/cassiopeia/');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'nasa1-1200.jpg']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testSearchFiles(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files?filter[search]=joomla');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'joomla_black.png']]]);
+		$I->dontSeeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'powered_by.png']]]);
+		$I->dontSeeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'banners']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint for a single file of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFile(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files/joomla_black.png');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'joomla_black.png']]]);
+		$I->dontSeeResponseContainsJson(['data' => ['attributes' => ['url' => $I->getConfig('url') . '/images/joomla_black.png']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint for a single file of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFileWithUrl(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files/joomla_black.png?url=1');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['url' => $I->getConfig('url') . '/images/joomla_black.png']]]);
+	}
+
+	/**
+	 * Test the GET media files endpoint for a single file of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testGetFolder(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendGET('/media/files/sampledata/cassiopeia');
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'cassiopeia']]]);
+	}
+
+	/**
+	 * Test the POST media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testCreateFile(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Content-Type', 'application/json');
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendPost(
+			'/media/files',
+			[
+				'path'    => $this->testDirectory . '/test.jpg',
+				'content' => base64_encode(file_get_contents(codecept_data_dir() . '/com_media/test-image-1.jpg'))
+			]
+		);
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'test.jpg']]]);
+	}
+
+	/**
+	 * Test the POST media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testCreateFolder(ApiTester $I)
+	{
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Content-Type', 'application/json');
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendPost(
+			'/media/files',
+			['path' => $this->testDirectory . '/test-from-create']
+		);
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'test-from-create']]]);
+	}
+
+	/**
+	 * Test the PATCH media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testUpdateFile(ApiTester $I)
+	{
+		file_put_contents($this->getImagesDirectory($I) . '/override.jpg', '1');
+
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Content-Type', 'application/json');
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendPatch(
+			'/media/files/' . $this->testDirectory . '/override.jpg',
+			[
+				'path'    => $this->testDirectory . '/override.jpg',
+				'content' => base64_encode(file_get_contents(codecept_data_dir() . '/com_media/test-image-1.jpg'))
+			]
+		);
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'file', 'name' => 'override.jpg']]]);
+		$I->dontSeeResponseContainsJson(['data' => ['attributes' => ['content' => '1']]]);
+	}
+
+	/**
+	 * Test the PATCH media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testUpdateFolder(ApiTester $I)
+	{
+		mkdir($this->getImagesDirectory($I) . '/override');
+
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Content-Type', 'application/json');
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendPatch(
+			'/media/files/' . $this->testDirectory . '/override',
+			['path'    => $this->testDirectory . '/override-new']
+		);
+
+		$I->seeResponseCodeIs(HttpCode::OK);
+		$I->seeResponseContainsJson(['data' => ['attributes' => ['type' => 'dir', 'name' => 'override-new']]]);
+	}
+
+	/**
+	 * Test the DELETE media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testDeleteFile(ApiTester $I)
+	{
+		touch($this->getImagesDirectory($I) . '/todelete.jpg');
+
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendDelete('/media/files/' . $this->testDirectory . '/todelete.jpg');
+
+		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
+	}
+
+	/**
+	 * Test the DELETE media files endpoint of com_media from the API.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  void
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	public function testDeleteFolder(ApiTester $I)
+	{
+		mkdir($this->getImagesDirectory($I) . '/todelete');
+
+		$I->amBearerAuthenticated($I->getBearerToken());
+		$I->haveHttpHeader('Accept', 'application/vnd.api+json');
+		$I->sendDelete('/media/files/' . $this->testDirectory . '/todelete');
+
+		$I->seeResponseCodeIs(HttpCode::NO_CONTENT);
+	}
+
+	/**
+	 * Returns the absolute tmp image folder path to work on.
+	 *
+	 * @param   mixed   ApiTester  $I  Api tester
+	 *
+	 * @return  string  The absolute folder path
+	 *
+	 * @since   __DEPLOY_VERSION__
+	 */
+	private function getImagesDirectory(ApiTester $I): string
+	{
+		return $I->getConfig('cmsPath') . '/images/' . $this->testDirectory;
+	}
+}

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions