src/Prismic/Api.php
<?php
declare(strict_types=1);
namespace Prismic;
use GuzzleHttp\Client;
use GuzzleHttp\ClientInterface;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Exception\RequestException;
use GuzzleHttp\Psr7\Uri;
use Prismic\Document\Hydrator;
use Prismic\Document\HydratorInterface;
use Prismic\Exception;
use Psr\Cache\CacheException;
use Psr\Cache\CacheItemInterface;
use Psr\Cache\CacheItemPoolInterface;
use function array_filter;
use function count;
use function filter_var;
use function json_decode;
use function md5;
use function parse_url;
use function preg_match;
use function sprintf;
use function str_replace;
use function strtolower;
use const FILTER_FLAG_PATH_REQUIRED;
use const FILTER_VALIDATE_URL;
/**
* This class embodies a connection to your prismic.io repository's API.
* Initialize it with Prismic::Api::get(), and use your Prismic::Api::forms() to make API calls
* (read more in <a href="https://github.com/prismicio/php-kit">the kit's README file</a>)
*/
class Api
{
/**
* Kit version number
* @deprecated
*/
public const VERSION = '4.0.0';
private const API_VERSION_1 = '1.0.0';
private const API_VERSION_2 = '2.0.0';
/**
* Name of the cookie that will be used to remember the preview reference
*/
public const PREVIEW_COOKIE = 'io.prismic.preview';
/**
* Name of the cookie that will be used to remember the experiment reference
*/
public const EXPERIMENTS_COOKIE = 'io.prismic.experiment';
/** @var string|null */
private $accessToken;
/** @var string */
private $url;
/** @var CacheItemPoolInterface */
private $cache;
/** @var ClientInterface */
private $httpClient;
/** @var HydratorInterface */
private $hydrator;
/**
* The API version determined by the URL passed to the named constructor
* @var string
*/
private $version;
/** @var LinkResolver|null */
private $linkResolver;
/** @var SearchFormCollection|null */
private $forms;
/**
* Request cookies to inspect for preview or experiment refs
*
* By default, this array is populated with the $_COOKIE super global but can be overridden with setRequestCookies()
*
* @var array
*/
private $requestCookies;
private function __construct()
{
$this->requestCookies = $_COOKIE ?? [];
}
/**
* This is the factory method with which to retrieve your API client instance
*
* If your API is set to "public" or "open", you can instantiate your Api object just like this:
* Api::get('https://your-repository-name.prismic.io/api/v2')
*
* @param string $action The URL of your repository API's endpoint
* @param string $accessToken A permanent access token to use if your repository API is set to private
* @param ClientInterface $httpClient Custom Guzzle http client
* @param CacheItemPoolInterface $cache Cache implementation
* @return self
*/
public static function get(
string $action,
?string $accessToken = null,
?ClientInterface $httpClient = null,
?CacheItemPoolInterface $cache = null
) : self {
$api = new static();
$api->accessToken = $accessToken === '' ? null : $accessToken;
$api->url = $action;
$api->httpClient = $httpClient ?? new Client();
$api->cache = $cache ?? Cache\DefaultCache::factory();
$api->version = preg_match('~/v2$~i', $action) ? static::API_VERSION_2 : static::API_VERSION_1;
$api->setHydrator(new Hydrator($api, [], Document::class));
return $api;
}
private function apiDataUrl() : string
{
$url = new Uri($this->url);
$url = $this->accessToken
? Uri::withQueryValue($url, 'access_token', $this->accessToken)
: $url;
return (string) $url;
}
private function apiDataCacheItem() : CacheItemInterface
{
$url = $this->apiDataUrl();
$key = static::generateCacheKey($url);
try {
return $this->cache->getItem($key);
} catch (CacheException $cacheException) {
throw new Exception\RuntimeException(
'A cache exception occurred whilst retrieving cached api data',
0,
$cacheException
);
}
}
private function getApiData() : ApiData
{
$url = $this->apiDataUrl();
try {
$response = $this->httpClient->request('GET', $url);
return ApiData::withJsonString((string) $response->getBody());
} catch (GuzzleException $guzzleException) {
throw Exception\RequestFailureException::fromGuzzleException($guzzleException);
}
}
public function getHydrator() : HydratorInterface
{
return $this->hydrator;
}
public function setHydrator(HydratorInterface $hydrator) : void
{
$this->hydrator = $hydrator;
}
public function setLinkResolver(LinkResolver $linkResolver) : void
{
$this->linkResolver = $linkResolver;
}
public function getLinkResolver() :? LinkResolver
{
return $this->linkResolver;
}
/**
* Set cookie values found in the request
*
* If preview and experiment cookie values are not available in your environment in the $_COOKIE super global, you
* can provide them here and they'll be inspected to see if a preview is required or an experiment is running
*
* @param array $cookies
*/
public function setRequestCookies(array $cookies) : void
{
$this->requestCookies = $cookies;
}
public static function generateCacheKey(string $url) : string
{
return md5($url);
}
public function getApiVersion() : string
{
return $this->version;
}
public function isV1Api() : bool
{
return $this->version === static::API_VERSION_1;
}
/**
* Returns all of the repository's references (queryable points in time)
*
* @return Ref[]
*/
public function refs() : array
{
$groupBy = [];
foreach ($this->getData()->getRefs() as $ref) {
$label = $ref->getLabel();
if (! isset($groupBy[$label])) {
$groupBy[$label] = $ref;
}
}
return $groupBy;
}
/**
* Return the ref identified by the given label
*
* @param string $label The label of the requested ref
*
* @return Ref|null a reference or null
*/
public function getRefFromLabel(string $label) :? Ref
{
$refs = $this->refs();
return $refs[$label];
}
/**
* Returns the list of all bookmarks on the repository. If you're looking
* for a document from it's bookmark name, you should use the bookmark() function.
*
* @return array the array of bookmarks
*/
public function bookmarks() : array
{
return $this->getData()->getBookmarks();
}
/**
* From a bookmark name, returns the ID of the attached document.
* You can then use this ID for anything, for instance to query with a predicate
* that looks like this [:d = at(document.id, "abcdefghijkl")].
* Most starter projects embed a helper to query a document from their ID string,
* which makes this even easier.
*
* @param string $name the bookmark name to use
*
* @return string|null the ID string for a given bookmark name
*/
public function bookmark(string $name) :? string
{
$bookmarks = $this->bookmarks();
return $bookmarks[$name] ?? null;
}
/**
* Returns the master ref repository: the ref which is to be used to query content
* that is live right now.
*
* @return Ref the master ref
*/
public function master() : Ref
{
$masters = array_filter($this->getData()->getRefs(), function (Ref $ref) {
return $ref->isMasterRef() === true;
});
return $masters[0];
}
/**
* Returns all forms of type Prismic::SearchForm that are available for this repository's API.
* The intended syntax of a call is: api->forms()->everything->query(query)->ref(ref)->submit().
* Learn more about those keywords in prismic.io's documentation on our developers' portal.
*/
public function forms() : SearchFormCollection
{
if (! $this->forms) {
$forms = [];
foreach ($this->getData()->getForms() as $name => $jsonObject) {
$formObject = Form::withJsonObject($jsonObject);
$data = $formObject->defaultData();
$forms[$name] = new SearchForm($this, $formObject, $data);
}
$this->forms = new SearchFormCollection($forms);
}
return $this->forms;
}
public function getExperiments() : Experiments
{
return $this->getData()->getExperiments();
}
/**
* Validate and filter a preview token ensuring that the URL it represents corresponds to the same host as the API
* @param string $token
* @return string The validated token
*/
private function validatePreviewToken(string $token) : string
{
// Even if the token has already been decoded, if it's a reasonable url,
// repeated decodes should not cause a problem.
$token = urldecode($token);
if (! filter_var($token, FILTER_VALIDATE_URL, FILTER_FLAG_PATH_REQUIRED)) {
throw new Exception\InvalidArgumentException(sprintf(
'The preview token "%s" is not a valid url',
$token
), 400);
}
['host' => $previewHost] = parse_url($token);
['host' => $apiHost] = parse_url($this->url);
/**
* Because the API host will possibly be name.cdn.prismic.io but the preview domain can be name.prismic.io
* we can only reliably verify the same parent domain name if we parse both domains with something that uses
* the public suffix list, like https://github.com/jeremykendall/php-domain-parser for example. We really
* don't want to have to go through all that, so for now we will just strip/hard-code the 'cdn' part which
* causes the problem.
*/
$previewHost = str_replace('.cdn.', '.', strtolower($previewHost));
$apiHost = str_replace('.cdn.', '.', strtolower($apiHost));
if ($previewHost !== $apiHost) {
throw new Exception\InvalidArgumentException(sprintf(
'The host "%s" does not match the api host "%s"',
$previewHost,
$apiHost
), 400);
}
return $token;
}
/**
* Return the URL to display a given preview
* @param string $token as received from Prismic server to identify the content to preview
* @param string $defaultUrl the URL to return if the preview doesn't correspond to a document
* @return string the URL you should redirect the user to preview the requested change
* @throws Exception\ExceptionInterface If there's a problem querying the API
*/
public function previewSession(string $token, string $defaultUrl) : string
{
try {
// $token is untrusted input, possibly from a GET request and will be retrieved by the http client
$token = $this->validatePreviewToken($token);
$response = $this->httpClient->request('GET', $token);
$responseBody = json_decode((string) $response->getBody());
if (isset($responseBody->mainDocument)) {
$document = $this->getById(
$responseBody->mainDocument,
['ref' => $token]
);
if ($document && $this->linkResolver) {
$url = $this->linkResolver->resolve($document->asLink());
return $url ?: $defaultUrl;
}
}
return $defaultUrl;
} catch (RequestException $requestException) {
$apiResponse = $requestException->getResponse();
if ($apiResponse && Exception\ExpiredPreviewTokenException::isTokenExpiryResponse($apiResponse)) {
throw Exception\ExpiredPreviewTokenException::fromResponse($apiResponse);
}
throw Exception\RequestFailureException::fromGuzzleException($requestException);
} catch (GuzzleException $exception) {
throw Exception\RequestFailureException::fromGuzzleException($exception);
}
}
/**
* Accessing raw data returned by the /api endpoint
*/
public function getData() : ApiData
{
$cacheItem = $this->apiDataCacheItem();
if ($cacheItem->isHit()) {
$data = $cacheItem->get();
if ($data instanceof ApiData) {
return $data;
}
}
$data = $this->getApiData();
$cacheItem->set($data);
$this->cache->save($cacheItem);
return $data;
}
/**
* Accessing the cache object specifying how to store the cache
*/
public function getCache() : CacheItemPoolInterface
{
return $this->cache;
}
/**
* Accessing the underlying Guzzle HTTP client
*/
public function getHttpClient() : ClientInterface
{
return $this->httpClient;
}
/**
* If a preview cookie is set, return the ref stored in that cookie
*/
private function getPreviewRef() :? string
{
$cookieNames = [
str_replace(['.',' '], '_', static::PREVIEW_COOKIE),
static::PREVIEW_COOKIE,
];
foreach ($cookieNames as $cookieName) {
if (isset($this->requestCookies[$cookieName])) {
return $this->requestCookies[$cookieName];
}
}
return null;
}
/**
* If an experiment cookie is set, return the ref as determined by \Prismic\Experiments::refFromCookie
*/
private function getExperimentRef() :? string
{
$cookieNames = [
str_replace(['.',' '], '_', static::EXPERIMENTS_COOKIE),
static::EXPERIMENTS_COOKIE,
];
foreach ($cookieNames as $cookieName) {
if (isset($this->requestCookies[$cookieName])) {
$experiments = $this->getExperiments();
return $experiments->refFromCookie($this->requestCookies[$cookieName]);
}
}
return null;
}
/**
* Whether the current ref in use is a preview, i.e. the user is in preview mode
*/
public function inPreview() : bool
{
return null !== $this->getPreviewRef();
}
/**
* Whether the current ref in use is an experiment
*/
public function inExperiment() : bool
{
return null !== $this->getExperimentRef() && false === $this->inPreview();
}
/**
* Return the ref currently in use
*
* In order of preference, returns the preview cookie, the experiments cookie or the master ref otherwise
*/
public function ref() : string
{
$preview = $this->getPreviewRef();
if ($preview) {
return $preview;
}
$experiment = $this->getExperimentRef();
if ($experiment) {
return $experiment;
}
return $this->master()->getRef();
}
/**
* Shortcut to query on the default reference.
* Use the reference from previews or experiment cookie, fallback to the master reference otherwise.
*
* @param string|array|Predicate $q the query, as a string, predicate or array of predicates
* @param array $options query options: pageSize, orderings, etc.
* @return Response
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function query($q, array $options = []) : Response
{
$options = $this->prepareDefaultQueryOptions($options);
$ref = $this->ref();
$form = $this->forms()->getForm('everything');
if (! $form) {
throw new Exception\RuntimeException('The form "everything" does not exist');
}
$form = $form->ref($ref);
if (! empty($q)) {
$form = $form->query($q);
}
foreach ($options as $key => $value) {
$form = $form->set($key, $value);
}
return $form->submit();
}
/**
* Return the first document matching the query
* Use the reference from previews or experiment cookie, fallback to the master reference otherwise.
*
* @param string|array|Predicate $q The query, as a string, predicate or array of predicates
* @param array $options Query options: pageSize, orderings, etc.
* @return DocumentInterface|null The resulting document, or null
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function queryFirst($q, array $options = []) :? DocumentInterface
{
$documents = $this->query($q, $options)->getResults();
if (count($documents) > 0) {
return $documents[0];
}
return null;
}
/**
* Search a document by its id
*
* @param string $id The requested id
* @param array $options Query options: pageSize, orderings, etc.
* @return DocumentInterface|null The resulting document (null if no match)
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function getById(string $id, array $options = []) :? DocumentInterface
{
return $this->queryFirst(Predicates::at('document.id', $id), $options);
}
/**
* Search a document by its uid
*
* @param string $type The custom type of the requested document
* @param string $uid The requested uid
* @param array $options Query options: pageSize, orderings, etc.
* @return DocumentInterface|null The resulting document (null if no match)
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function getByUid(string $type, string $uid, array $options = []) :? DocumentInterface
{
return $this->queryFirst(
Predicates::at(sprintf(
'my.%s.uid',
$type
), $uid),
$options
);
}
/**
* Return a set of document from their ids
*
* @param array $ids array of strings, the requested ids
* @param array $options query options: pageSize, orderings, etc.
* @return Response the response, including documents and pagination information
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function getByIds(array $ids, array $options = []) : Response
{
return $this->query(Predicates::in('document.id', $ids), $options);
}
/**
* Get a single typed document by its type
*
* @param string $type The custom type of the requested document
* @param array $options Query options: pageSize, orderings, etc.
* @return DocumentInterface|null The resulting document (null if no match)
* @throws Exception\ExceptionInterface if parameters are invalid
*/
public function getSingle(string $type, array $options = []) :? DocumentInterface
{
return $this->queryFirst(Predicates::at('document.type', $type), $options);
}
/**
* Given an options array for a query, fill the lang parameter with a default value
* @param array $options
* @return array
*/
private function prepareDefaultQueryOptions(array $options) : array
{
if (! isset($options['lang'])) {
$options['lang'] = '*';
}
return $options;
}
}