Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[4.1] Media webservice #68

Closed
joomlapl-bot opened this issue Nov 23, 2021 · 0 comments
Closed

[4.1] Media webservice #68

joomlapl-bot opened this issue Nov 23, 2021 · 0 comments

Comments

@joomlapl-bot
Copy link
Collaborator

PR w związku ze zmianą oryginału joomla/joomla-cms#35788 Poniżej zmiany w oryginale:

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;
+	}
+}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

3 participants