├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── lib ├── BlockContent.php ├── BlockContent │ ├── Escaper.php │ ├── HtmlBuilder.php │ ├── Node.php │ ├── Serializers │ │ ├── DefaultBlock.php │ │ ├── DefaultImage.php │ │ ├── DefaultList.php │ │ ├── DefaultListItem.php │ │ └── DefaultSpan.php │ ├── TreeBuilder.php │ └── TypeHandlers │ │ ├── BlockHandler.php │ │ ├── DefaultHandler.php │ │ └── ListHandler.php ├── Client.php ├── Exception │ ├── BaseException.php │ ├── ClientException.php │ ├── ConfigException.php │ ├── InvalidArgumentException.php │ ├── RequestException.php │ └── ServerException.php ├── ParameterSerializer.php ├── Patch.php ├── Selection.php ├── Transaction.php ├── Util │ └── DocumentPropertyAsserter.php └── Version.php ├── phpcs.xml ├── phpunit.xml ├── renovate.json └── test ├── BlockContentHtmlTest.php ├── BlockContentMigrationTest.php ├── BlockContentTreeTest.php ├── ClientTest.php ├── PatchTest.php ├── Serializers └── MyCustomImageSerializer.php ├── TestCase.php ├── TransactionTest.php ├── bootstrap.php └── fixtures ├── bold-underline-text.json ├── custom-block.json ├── dangerous-text.json ├── document.json ├── empty.txt ├── favicon.png ├── h2-text.json ├── image-with-caption.json ├── images.json ├── italicized-text.json ├── link-author-text.json ├── link-messy-text-new.json ├── link-messy-text.json ├── link-simple-text.json ├── list-both-types-blocks.json ├── list-bulleted-blocks.json ├── list-numbered-blocks.json ├── marks-ordered-text.json ├── marks-reordered-text.json ├── messy-text.json ├── non-block.json ├── normal-text.json └── underlined-text.json /.editorconfig: -------------------------------------------------------------------------------- 1 | ; editorconfig.org 2 | root = true 3 | charset= utf8 4 | 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | trim_trailing_whitespace = true 9 | indent_style = space 10 | indent_size = 4 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | ci: 7 | runs-on: ubuntu-20.04 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | php-version: 12 | - '5.6' 13 | - '7.0' 14 | - '7.1' 15 | - '7.2' 16 | - '7.3' 17 | - '7.4' 18 | - '8.0' 19 | - '8.1' 20 | - '8.2' 21 | name: CI workflow on PHP ${{ matrix.php-version }} 22 | steps: 23 | - uses: actions/checkout@v4 24 | 25 | - name: Setup PHP 26 | uses: shivammathur/setup-php@v2 27 | with: 28 | php-version: ${{ matrix.php-version }} 29 | 30 | - name: Validate composer files 31 | run: composer validate --strict 32 | 33 | - name: Get Composer Cache Directory 34 | id: composer-cache-dir 35 | run: | 36 | echo "::set-output name=dir::$(composer config cache-files-dir)" 37 | 38 | - name: Cache Composer packages 39 | id: composer-cache 40 | uses: actions/cache@v2 41 | with: 42 | path: ${{ steps.composer-cache-dir.outputs.dir }} 43 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 44 | restore-keys: | 45 | ${{ runner.os }}-composer- 46 | 47 | - name: Install dependencies 48 | run: | 49 | composer install --prefer-dist 50 | 51 | - name: Run tests 52 | run: composer run test 53 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | build 2 | tests/phpunit.xml 3 | composer.phar 4 | vendor 5 | test.php 6 | .lintcache 7 | docs/_build 8 | package.json 9 | *.sublime-workspace 10 | clover.xml 11 | /coverage 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Sanity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # sanity-php 2 | 3 | [![Packagist](https://img.shields.io/packagist/v/sanity/sanity-php.svg?style=flat-square)](https://packagist.org/packages/sanity/sanity-php) 4 | 5 | PHP library for the [Sanity API](https://sanity.io/) 6 | 7 | ## Requirements 8 | 9 | sanity-php requires PHP >= 5.6, with the `json` module installed. 10 | 11 | ## Composer 12 | 13 | You can install the library via [Composer](http://getcomposer.org/). Run the following command: 14 | 15 | ```bash 16 | composer require sanity/sanity-php 17 | ``` 18 | 19 | To use the library, use Composer's [autoload](https://getcomposer.org/doc/00-intro.md#autoloading): 20 | 21 | ```php 22 | require_once 'vendor/autoload.php'; 23 | ``` 24 | 25 | ## Usage 26 | 27 | ### Instantiating a new client 28 | 29 | ```php 30 | use Sanity\Client as SanityClient; 31 | 32 | $client = new SanityClient([ 33 | 'projectId' => 'your-project-id', 34 | 'dataset' => 'your-dataset-name', 35 | // Whether or not to use the API CDN for queries. Default is false. 36 | 'useCdn' => true, 37 | // If you are starting a new project, using the current UTC date is usually 38 | // a good idea. See "Specifying API version" section for more details 39 | 'apiVersion' => '2019-01-29', 40 | ]); 41 | ``` 42 | 43 | ### Using an authorization token 44 | 45 | ```php 46 | $client = new SanityClient([ 47 | 'projectId' => 'your-project-id', 48 | 'dataset' => 'your-dataset-name', 49 | 'useCdn' => false, 50 | 'apiVersion' => '2019-01-29', 51 | // Note that you cannot combine a token with the `useCdn` option set to true, 52 | // as authenticated requests cannot be cached 53 | 'token' => 'sanity-auth-token', 54 | ]); 55 | ``` 56 | 57 | ### Specifying API version 58 | 59 | Sanity uses ISO dates (YYYY-MM-DD) in UTC timezone for versioning. The explanation for this can be found [in the documentation](http://sanity.io/help/api-versioning) 60 | 61 | In general, unless you know what API version you want to use, you'll want to set it to todays UTC date. By doing this, you'll get all the latest bugfixes and features, while preventing any timezone confusion and locking the API to prevent breaking changes. 62 | 63 | **Note**: Do not be tempted to use a dynamic value for the `apiVersion`. The whole reason for setting a static value is to prevent unexpected, breaking changes. 64 | 65 | In future versions, specifying an API version will be required. For now, to maintain backwards compatiblity, not specifying a version will trigger a deprecation warning and fall back to using `v1`. 66 | 67 | ### Fetch a single document by ID 68 | 69 | ```php 70 | $document = $client->getDocument('someDocumentId'); 71 | ``` 72 | 73 | ### Performing queries 74 | 75 | ```php 76 | $results = $client->fetch( 77 | '*[_type == $type][0...3]', // Query 78 | ['type' => 'product'] // Params (optional) 79 | ); 80 | 81 | foreach ($product in $results) { 82 | echo $product['title'] . '\n'; 83 | } 84 | ``` 85 | 86 | See the [query documentation](https://www.sanity.io/docs/front-ends/query-cheat-sheet) for more information on how to write queries. 87 | 88 | ### Using perspectives 89 | 90 | The `perspective` option can be used to specify special filtering behavior for queries. The default value is `raw`, which means no special filtering is applied, while [`published`](#published) and [`previewDrafts`](#previewdrafts) can be used to optimize for specific use cases. 91 | 92 | #### `published` 93 | 94 | Useful for when you want to be sure that draft documents are not returned in production. Pairs well with private datasets. 95 | 96 | With a dataset that looks like this: 97 | 98 | ```json 99 | [ 100 | { 101 | "_type": "author", 102 | "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43", 103 | "name": "George Martin" 104 | }, 105 | { 106 | "_type": "author", 107 | "_id": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43", 108 | "name": "George R.R. Martin" 109 | }, 110 | { 111 | "_type": "author", 112 | "_id": "drafts.f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 113 | "name": "Stephen King" 114 | } 115 | ] 116 | ``` 117 | 118 | And a query like this: 119 | 120 | ```php 121 | $client = new SanityClient([ 122 | // ...config... 123 | 'useCdn' => true, 124 | 'perspective' => 'published', 125 | ]); 126 | 127 | $authors = $client->fetch('*[_type == "author"]'); 128 | ``` 129 | 130 | Then `$authors` will only contain documents that don't have a `drafts.` prefix in their `_id`, in this case just "George Martin": 131 | 132 | ```json 133 | [ 134 | { 135 | "_type": "author", 136 | "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43", 137 | "name": "George Martin" 138 | } 139 | ] 140 | ``` 141 | 142 | #### `previewDrafts` 143 | 144 | Designed to help answer the question "What is our app going to look like after all the draft documents are published?". 145 | 146 | Given a dataset like this: 147 | 148 | ```json 149 | [ 150 | { 151 | "_type": "author", 152 | "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43", 153 | "name": "George Martin" 154 | }, 155 | { 156 | "_type": "author", 157 | "_id": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43", 158 | "name": "George R.R. Martin" 159 | }, 160 | { 161 | "_type": "author", 162 | "_id": "drafts.f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 163 | "name": "Stephen King" 164 | }, 165 | { 166 | "_type": "author", 167 | "_id": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75", 168 | "name": "Terry Pratchett" 169 | } 170 | ] 171 | ``` 172 | 173 | And a query like this: 174 | 175 | ```php 176 | $client = new SanityClient([ 177 | // ...config... 178 | 'useCdn' => false, // the `previewDrafts` perspective requires this to be `false` 179 | 'perspective' => 'previewDrafts', 180 | ]); 181 | 182 | $authors = $client->fetch('*[_type == "author"]'); 183 | ``` 184 | 185 | Then `authors` will look like this. Note that the result dedupes documents with a preference for the draft version: 186 | 187 | ```json 188 | [ 189 | { 190 | "_type": "author", 191 | "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43", 192 | "_originalId": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43", 193 | "name": "George R.R. Martin" 194 | }, 195 | { 196 | "_type": "author", 197 | "_id": "f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 198 | "_originalId": "drafts.f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 199 | "name": "Stephen King" 200 | }, 201 | { 202 | "_type": "author", 203 | "_id": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75", 204 | "_originalId": "6b3792d2-a9e8-4c79-9982-c7e89f2d1e75", 205 | "name": "Terry Pratchett" 206 | } 207 | ] 208 | ``` 209 | 210 | Since the query simulates what the result will be after publishing the drafts, the `_id` doesn't contain the `drafts.` prefix. If you want to check if a document is a draft or not you can use the `_originalId` field, which is only available when using the `previewDrafts` perspective. 211 | 212 | ```php 213 | $authors = $client->fetch('*[_type == "author"]{..., "status": select( 214 | _originalId in path("drafts.**") => "draft", 215 | "published" 216 | )}'); 217 | ``` 218 | 219 | Which changes the result to be: 220 | 221 | ```json 222 | [ 223 | { 224 | "_type": "author", 225 | "_id": "ecfef291-60f0-4609-bbfc-263d11a48c43", 226 | "_originalId": "drafts.ecfef291-60f0-4609-bbfc-263d11a48c43", 227 | "name": "George R.R. Martin", 228 | "status": "draft" 229 | }, 230 | { 231 | "_type": "author", 232 | "_id": "f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 233 | "_originalId": "f4898efe-92c4-4dc0-9c8c-f7480aef17e2", 234 | "name": "Stephen King", 235 | "status": "published" 236 | } 237 | ] 238 | ``` 239 | 240 | ### Creating documents 241 | 242 | ```php 243 | $doc = [ 244 | '_type' => 'bike', 245 | 'name' => 'Bengler Tandem Extraordinaire', 246 | 'seats' => 2, 247 | ]; 248 | 249 | $newDocument = $client->create($doc); 250 | echo 'Bike was created, document ID is ' . $newDocument['_id']; 251 | ``` 252 | 253 | This creates a new document with the given properties. It must contain a `_type` attribute, and _may_ contain a `_id` attribute. If an ID is specified and a document with that ID already exist, the mutation will fail. If an ID is not specified, it will be auto-generated and is included in the returned document. 254 | 255 | ### Creating a document (if it does not exist) 256 | 257 | As noted above, if you include an `_id` property when calling `create()` and a document with this ID already exists, it will fail. If you instead want to ignore the create operation if it exists, you can use `createIfNotExists()`. It takes the same arguments as `create()`, the only difference being that it *requires* an `_id` attribute. 258 | 259 | ```php 260 | $doc = [ 261 | '_id' => 'my-document-id', 262 | '_type' => 'bike', 263 | 'name' => 'Amazing bike', 264 | 'seats' => 3, 265 | ]; 266 | 267 | $newDocument = $client->createIfNotExists($doc); 268 | ``` 269 | 270 | ### Replacing a document 271 | 272 | If you don't care whether or not a document exists already and just want to replace it, you can use the `createOrReplace()` method. 273 | 274 | ```php 275 | $doc = [ 276 | '_id' => 'my-document-id', 277 | '_type' => 'bike', 278 | 'name' => 'Amazing bike', 279 | 'seats' => 3, 280 | ]; 281 | 282 | $newDocument = $client->createOrReplace($doc); 283 | ``` 284 | 285 | ### Patch/update a document 286 | 287 | ```php 288 | use Sanity\Exception\BaseException; 289 | 290 | try { 291 | $updatedBike = $client 292 | ->patch('bike-123') // Document ID to patch 293 | ->set(['inStock' => false]) // Shallow merge 294 | ->inc(['numSold' => 1]) // Increment field by count 295 | ->commit(); // Perform the patch and return the modified document 296 | } catch (BaseException $error) { 297 | echo 'Oh no, the update failed: '; 298 | var_dump($error); 299 | } 300 | ``` 301 | 302 | Todo: Document all patch operations 303 | 304 | ### Delete a document 305 | 306 | ```php 307 | use Sanity\Exception\BaseException; 308 | 309 | try { 310 | $client->delete('bike-123'); 311 | } catch (BaseException $error) { 312 | echo 'Delete failed: '; 313 | var_dump($error); 314 | } 315 | ``` 316 | 317 | ### Multiple mutations in a transaction 318 | 319 | ```php 320 | $namePatch = $client->patch('bike-310')->set(['name' => 'A Bike To Go']); 321 | 322 | try { 323 | $client->transaction() 324 | ->create(['name' => 'Bengler Tandem Extraordinaire', 'seats' => 2]) 325 | ->delete('bike-123') 326 | ->patch($namePatch) 327 | ->commit(); 328 | 329 | echo 'A whole lot of stuff just happened!'; 330 | } catch (BaseException $error) { 331 | echo 'Transaction failed:'; 332 | var_dump($error); 333 | } 334 | ``` 335 | 336 | ### Clientless patches & transactions 337 | 338 | ```php 339 | use Sanity\Patch; 340 | use Sanity\Transaction; 341 | 342 | // Patches: 343 | $patch = new Patch(''); 344 | $patch->inc(['count' => 1])->unset(['visits']); 345 | $client->mutate($patch); 346 | 347 | // Transactions: 348 | $transaction = new Transaction(); 349 | $transaction 350 | ->create(['_id' => '123', 'name' => 'FooBike']) 351 | ->delete('someDocId'); 352 | 353 | $client->mutate($transaction); 354 | ``` 355 | 356 | An important note on this approach is that you cannot call `commit()` on transactions or patches instantiated this way, instead you have to pass them to `client.mutate()`. 357 | 358 | ### Upload an image asset (from local file) 359 | 360 | ```php 361 | $asset = $client->uploadAssetFromFile('image', '/some/path/to/image.png'); 362 | echo $asset['_id']; 363 | ``` 364 | 365 | ### Upload an image asset (from a string) 366 | 367 | ```php 368 | $image = file_get_contents('/some/path/to/image.png'); 369 | $asset = $client->uploadAssetFromString('image', $buffer, [ 370 | // Will be set in the `originalFilename` property on the image asset 371 | // The filename in the URL will still be a hash 372 | 'filename' => 'magnificent-bridge.png' 373 | ]); 374 | echo $asset['_id']; 375 | ``` 376 | 377 | ### Upload image, extract exif and palette data 378 | 379 | ```php 380 | $asset = $client->uploadAssetFromFile('image', '/some/path/to/image.png', [ 381 | 'extract' => ['exif', 'palette'] 382 | ]); 383 | 384 | var_dump($asset['metadata']); 385 | ``` 386 | 387 | ### Upload a file asset (from local file) 388 | 389 | ```php 390 | $asset = $client->uploadAssetFromFile('file', '/path/to/raspberry-pi-specs.pdf', [ 391 | // Including a mime type is not _required_ but strongly recommended 392 | 'contentType' => 'application/pdf' 393 | ]); 394 | echo $asset['_id']; 395 | ``` 396 | 397 | ### Upload a file asset (from a string) 398 | 399 | ```php 400 | $image = file_get_contents('/path/to/app-release.apk'); 401 | $asset = $client->uploadAssetFromString('file', $buffer, [ 402 | // Will be set in the `originalFilename` property on the image asset 403 | // The filename in the URL will still be a hash 404 | 'filename' => 'dog-walker-pro-v1.4.33.apk', 405 | // Including a mime type is not _required_ but strongly recommended 406 | 'contentType' => 'application/vnd.android.package-archive' 407 | ]); 408 | echo $asset['_id']; 409 | ``` 410 | 411 | ### Referencing an uploaded image/file 412 | 413 | ```php 414 | // Create a new document with the referenced image in the "image" field: 415 | $asset = $client->uploadAssetFromFile('image', '/some/path/to/image.png'); 416 | $document = $client->create([ 417 | '_type' => 'blogPost', 418 | 'image' => [ 419 | '_type' => 'image', 420 | 'asset' => ['_ref' => $asset['_id']] 421 | ] 422 | ]); 423 | echo $document['_id']; 424 | ``` 425 | 426 | ```php 427 | // Patch existing document, setting the `heroImage` field 428 | $asset = $client->uploadAssetFromFile('image', '/some/path/to/image.png'); 429 | $updatedBike = $client 430 | ->patch('bike-123') // Document ID to patch 431 | ->set([ 432 | 'heroImage' => [ 433 | '_type' => 'image', 434 | 'asset' => ['_ref' => $asset['_id']] 435 | ] 436 | ]) 437 | ->commit(); 438 | ``` 439 | 440 | ### Upload image and append to array 441 | 442 | ```php 443 | $asset = $client->uploadAssetFromFile('image', '/some/path/to/image.png'); 444 | $updatedHotel = $client 445 | ->patch('hotel-coconut-lounge') // Document ID to patch 446 | ->setIfMissing(['roomPhotos' => []]) // Ensure we have an array to append to 447 | ->append('roomPhotos', [ 448 | [ 449 | '_type' => 'image', 450 | '_key' => bin2hex(random_bytes(5)), 451 | 'asset' => ['_ref' => $image['_id']] 452 | ] 453 | ]) 454 | ->commit(); 455 | ``` 456 | 457 | ### Get client configuration 458 | 459 | ```php 460 | $config = $client->config(); 461 | echo $config['dataset']; 462 | ``` 463 | 464 | ### Set client configuration 465 | 466 | ```php 467 | $client->config(['dataset' => 'newDataset']); 468 | ``` 469 | 470 | The new configuration will be merged with the existing, so you only need to pass the options you want to modify. 471 | 472 | ### Rendering block content 473 | 474 | When you use the block editor in Sanity, it produces a structured array structure that you can use to render the content on any platform you might want. In PHP, a common output format is HTML. To make the transformation from the array structure to HTML simpler, we include a helper class for this within the library. 475 | 476 | If your content only contains the basic, built-in block types, you can get rendered HTML like this: 477 | 478 | ```php 479 | use Sanity\BlockContent; 480 | 481 | $document = $client->getDocument('some-doc'); 482 | $article = $document['article']; // The field that contains your block content 483 | 484 | $html = BlockContent::toHtml($article, [ 485 | 'projectId' => 'abc123', 486 | 'dataset' => 'bikeshop', 487 | 'imageOptions' => ['w' => 320, 'h' => 240] 488 | ]); 489 | ``` 490 | 491 | If you have some custom types, or would like to customize the rendering, you may pass an associative array of serializers: 492 | 493 | ```php 494 | $html = BlockContent::toHtml($article, [ 495 | 'serializers' => [ 496 | 'listItem' => function ($item, $parent, $htmlBuilder) { 497 | return '
  • ' . implode('\n', $item['children']) . '
  • '; 498 | }, 499 | 'geopoint' => function ($item) { 500 | $attrs = $item['attributes'] 501 | $url = 'https://www.google.com/maps/embed/v1/place?key=someApiKey¢er=' 502 | $url .= $attrs['lat'] . ',' . $attrs['lng']; 503 | return '' 504 | }, 505 | 'pet' => function ($item, $parent, $htmlBuilder) { 506 | return '

    ' . $htmlBuilder->escape($item['attributes']['name']) . '

    '; 507 | } 508 | ] 509 | ]); 510 | ``` 511 | 512 | ## Contributing 513 | 514 | `sanity-php` follows the [PSR-2 Coding Style Guide](http://www.php-fig.org/psr/psr-2/). Contributions are welcome, but must conform to this standard. 515 | 516 | ## License 517 | 518 | MIT-licensed. See LICENSE 519 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sanity/sanity-php", 3 | "description": "PHP library for the Sanity API", 4 | "type": "library", 5 | "scripts": { 6 | "cs": "vendor/bin/phpcs lib", 7 | "phpunit": "vendor/bin/phpunit", 8 | "test": [ 9 | "@phpunit", 10 | "@cs" 11 | ] 12 | }, 13 | "require": { 14 | "php": ">=5.6", 15 | "guzzlehttp/guzzle": "^6.2|^7.0" 16 | }, 17 | "require-dev": { 18 | "phpunit/phpunit": "^5.7", 19 | "squizlabs/php_codesniffer": "^2.8" 20 | }, 21 | "autoload": { 22 | "psr-4": { 23 | "Sanity\\" : "lib/", 24 | "SanityTest\\": "test/" 25 | } 26 | }, 27 | "license": "MIT", 28 | "authors": [ 29 | { 30 | "name": "Sanity.io", 31 | "email": "hello@sanity.io" 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /lib/BlockContent.php: -------------------------------------------------------------------------------- 1 | build($content); 16 | } 17 | 18 | public static function toHtml($content, $options = []) 19 | { 20 | $htmlBuilder = new HtmlBuilder($options); 21 | $tree = static::isTree($content) ? $content : static::toTree($content); 22 | return $htmlBuilder->build($tree); 23 | } 24 | 25 | public static function isTree($tree) 26 | { 27 | return !isset($tree['_type']) && !isset($tree[0]['_type']); 28 | } 29 | 30 | public static function migrate($content, $options = []) 31 | { 32 | if (isset($content['_type'])) { 33 | return self::migrateBlock($content, $options); 34 | } 35 | 36 | if (is_array($content) && isset($content[0]['_type'])) { 37 | return array_map(function ($block) use ($options) { 38 | return self::migrateBlock($block, $options); 39 | }, $content); 40 | } 41 | 42 | throw new InvalidArgumentException('Unrecognized data structure'); 43 | } 44 | 45 | public static function migrateBlock($content, $options = []) 46 | { 47 | $defaults = ['version' => 2]; 48 | 49 | $options = array_merge($defaults, $options); 50 | $keyGenerator = self::$useStaticKeys 51 | ? function ($item) { 52 | return substr(md5(serialize($item)), 0, 7); 53 | } 54 | : function () { 55 | return uniqid(); 56 | }; 57 | 58 | if ($options['version'] != 2) { 59 | throw new InvalidArgumentException('Unsupported version'); 60 | } 61 | 62 | // We only support v1 to v2 for now, so no need for a switch 63 | if (!isset($content['spans'])) { 64 | return $content; 65 | } 66 | 67 | $migrated = $content; 68 | $markDefs = []; 69 | $migrated['children'] = array_map( 70 | function ($child) use (&$markDefs, $keyGenerator) { 71 | $knownKeys = ['_type', 'text', 'marks']; 72 | $unknownKeys = array_diff(array_keys($child), $knownKeys); 73 | 74 | foreach ($unknownKeys as $key) { 75 | $markKey = $keyGenerator($child[$key]); 76 | $child['marks'][] = $markKey; 77 | $markDefs[] = array_merge($child[$key], [ 78 | '_key' => $markKey, 79 | '_type' => $key, 80 | ]); 81 | 82 | unset($child[$key]); 83 | } 84 | 85 | return $child; 86 | }, 87 | $content['spans'] 88 | ); 89 | 90 | $migrated['markDefs'] = $markDefs; 91 | unset($migrated['spans']); 92 | 93 | return $migrated; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /lib/BlockContent/Escaper.php: -------------------------------------------------------------------------------- 1 | true, 'UTF-8' => true]; 24 | } else { 25 | $htmlspecialcharsCharsets = [ 26 | 'ISO-8859-1' => true, 'ISO8859-1' => true, 27 | 'ISO-8859-15' => true, 'ISO8859-15' => true, 28 | 'utf-8' => true, 'UTF-8' => true, 29 | 'CP866' => true, 'IBM866' => true, '866' => true, 30 | 'CP1251' => true, 'WINDOWS-1251' => true, 'WIN-1251' => true, 31 | '1251' => true, 32 | 'CP1252' => true, 'WINDOWS-1252' => true, '1252' => true, 33 | 'KOI8-R' => true, 'KOI8-RU' => true, 'KOI8R' => true, 34 | 'BIG5' => true, '950' => true, 35 | 'GB2312' => true, '936' => true, 36 | 'BIG5-HKSCS' => true, 37 | 'SHIFT_JIS' => true, 'SJIS' => true, '932' => true, 38 | 'EUC-JP' => true, 'EUCJP' => true, 39 | 'ISO8859-5' => true, 'ISO-8859-5' => true, 'MACROMAN' => true, 40 | ]; 41 | } 42 | } 43 | 44 | if (isset($htmlspecialcharsCharsets[$charset])) { 45 | return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); 46 | } 47 | 48 | if (isset($htmlspecialcharsCharsets[strtoupper($charset)])) { 49 | // cache the lowercase variant for future iterations 50 | $htmlspecialcharsCharsets[$charset] = true; 51 | 52 | return htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, $charset); 53 | } 54 | 55 | $string = iconv($charset, 'UTF-8', $string); 56 | $string = htmlspecialchars($string, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'); 57 | 58 | return iconv('UTF-8', $charset, $string); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /lib/BlockContent/HtmlBuilder.php: -------------------------------------------------------------------------------- 1 | serializers = array_replace_recursive($this->getDefaultSerializers(), $serializers); 18 | $this->charset = isset($options['charset']) ? $options['charset'] : 'utf-8'; 19 | $this->imageOptions = isset($options['imageOptions']) ? $options['imageOptions'] : []; 20 | $this->projectId = isset($options['projectId']) ? $options['projectId'] : null; 21 | $this->dataset = isset($options['dataset']) ? $options['dataset'] : null; 22 | } 23 | 24 | public function build($content, $parent = null) 25 | { 26 | if (is_string($content)) { 27 | return $this->escape($content); 28 | } 29 | 30 | $nodes = isset($content['type']) ? [$content] : $content; 31 | $html = ''; 32 | foreach ($nodes as $node) { 33 | $children = []; 34 | $content = []; 35 | if (isset($node['content'])) { 36 | $content = $node['content']; 37 | } elseif (isset($node['items'])) { 38 | $content = $this->wrapInListItems($node['items']); 39 | } 40 | 41 | foreach ($content as $child) { 42 | $children[] = $this->build($child, $node); 43 | } 44 | 45 | $values = $node; 46 | $values['children'] = $children; 47 | 48 | if (!isset($this->serializers[$node['type']])) { 49 | throw new ConfigException('No serializer registered for node type "' . $node['type'] . '"'); 50 | } 51 | 52 | $serializer = $this->serializers[$node['type']]; 53 | $serialized = call_user_func($serializer, $values, $parent, $this); 54 | $html .= $serialized; 55 | } 56 | 57 | return $html; 58 | } 59 | 60 | public function escape($string, $charset = null) 61 | { 62 | $charset = $charset ?: $this->charset; 63 | return Escaper::escape($string, $charset); 64 | } 65 | 66 | public function __invoke($content) 67 | { 68 | return $this->build($content); 69 | } 70 | 71 | public function getMarkSerializer($mark) 72 | { 73 | $markName = isset($mark['_type']) ? $mark['_type'] : $mark; 74 | 75 | return isset($this->serializers['marks'][$markName]) 76 | ? $this->serializers['marks'][$markName] 77 | : null; 78 | } 79 | 80 | public function getImageOptions() 81 | { 82 | return $this->imageOptions; 83 | } 84 | 85 | public function getProjectId() 86 | { 87 | return $this->projectId; 88 | } 89 | 90 | public function getDataset() 91 | { 92 | return $this->dataset; 93 | } 94 | 95 | private function wrapInListItems($items) 96 | { 97 | return array_map(function ($item) { 98 | return ['type' => 'listItem', 'content' => [$item]]; 99 | }, $items); 100 | } 101 | 102 | private function getDefaultSerializers() 103 | { 104 | return [ 105 | 'block' => new Serializers\DefaultBlock(), 106 | 'list' => new Serializers\DefaultList(), 107 | 'listItem' => new Serializers\DefaultListItem(), 108 | 'span' => new Serializers\DefaultSpan(), 109 | 'image' => new Serializers\DefaultImage(), 110 | 'marks' => [ 111 | 'em' => 'em', 112 | 'code' => 'code', 113 | 'strong' => 'strong', 114 | 'underline' => [ 115 | 'head' => '', 116 | 'tail' => '', 117 | ], 118 | 'link' => [ 119 | 'head' => function ($mark) { 120 | return ''; 121 | }, 122 | 'tail' => '' 123 | ] 124 | ] 125 | ]; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /lib/BlockContent/Node.php: -------------------------------------------------------------------------------- 1 | type = $node['type']; 18 | $this->mark = $node['mark']; 19 | $this->markKey = $node['markKey']; 20 | $this->content = $node['content']; 21 | } 22 | 23 | public function addContent($node) 24 | { 25 | $this->content[] = $node; 26 | return $this; 27 | } 28 | 29 | public function serialize() 30 | { 31 | $node = []; 32 | 33 | if ($this->type) { 34 | $node['type'] = $this->type; 35 | } 36 | 37 | if ($this->mark) { 38 | $node['mark'] = $this->mark; 39 | } 40 | 41 | if (!empty($this->content)) { 42 | $node['content'] = array_map(function ($child) { 43 | return $child instanceof Node ? $child->serialize() : $child; 44 | }, $this->content); 45 | } 46 | 47 | return $node; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/BlockContent/Serializers/DefaultBlock.php: -------------------------------------------------------------------------------- 1 | ' . implode('', $block['children']) . ''; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /lib/BlockContent/Serializers/DefaultImage.php: -------------------------------------------------------------------------------- 1 | getImageUrl($item, $htmlBuilder); 11 | return '
    '; 12 | } 13 | 14 | protected function getImageUrl($item, $htmlBuilder) 15 | { 16 | $helpUrl = 'https://github.com/sanity-io/sanity-php#rendering-block-content'; 17 | 18 | $projectId = $htmlBuilder->getProjectId(); 19 | $dataset = $htmlBuilder->getDataset(); 20 | $imageOptions = $htmlBuilder->getImageOptions(); 21 | 22 | $node = $item['attributes']; 23 | $asset = isset($node['asset']) ? $node['asset'] : null; 24 | 25 | if (!$asset) { 26 | throw new ConfigException('Image does not have required `asset` property'); 27 | } 28 | 29 | $qs = http_build_query($imageOptions); 30 | if (!empty($qs)) { 31 | $qs = '?' . $qs; 32 | } 33 | 34 | if (isset($asset['url'])) { 35 | return $asset['url'] . $qs; 36 | } 37 | 38 | $ref = isset($asset['_ref']) ? $asset['_ref'] : null; 39 | if (!$ref) { 40 | throw new ConfigException('Invalid image reference in block, no `_ref` found on `asset`'); 41 | } 42 | 43 | if (!$projectId || !$dataset) { 44 | throw new ConfigException( 45 | '`projectId` and/or `dataset` missing from block content config, see ' . $helpUrl 46 | ); 47 | } 48 | 49 | $parts = explode('-', $ref); 50 | $url = 'https://cdn.sanity.io/' 51 | . $parts[0] . 's/' // Asset type, pluralized 52 | . $projectId . '/' 53 | . $dataset . '/' 54 | . $parts[1] . '-' // Asset ID 55 | . $parts[2] . '.' // Dimensions 56 | . $parts[3] // File extension 57 | . $qs; // Query string 58 | 59 | return $url; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /lib/BlockContent/Serializers/DefaultList.php: -------------------------------------------------------------------------------- 1 | ' . implode('', $list['children']) . ''; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lib/BlockContent/Serializers/DefaultListItem.php: -------------------------------------------------------------------------------- 1 | ' . implode('', $item['children']) . ''; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /lib/BlockContent/Serializers/DefaultSpan.php: -------------------------------------------------------------------------------- 1 | getMarkSerializer($span['mark']) 12 | : null; 13 | 14 | if ($mark && is_string($mark)) { 15 | $head .= '<' . $mark . '>'; 16 | $tail .= ''; 17 | } elseif ($mark && is_callable($mark)) { 18 | return $mark($span['mark'], $span['children']); 19 | } elseif ($mark && is_array($mark)) { 20 | $head .= is_callable($mark['head']) 21 | ? $mark['head']($span['mark']) 22 | : $mark['head']; 23 | 24 | $tail .= is_callable($mark['tail']) 25 | ? $mark['tail']($span['mark']) 26 | : $mark['tail']; 27 | } 28 | 29 | return $head . implode('', $span['children']) . $tail; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /lib/BlockContent/TreeBuilder.php: -------------------------------------------------------------------------------- 1 | typeHandlers = [ 13 | 'block' => new TypeHandlers\BlockHandler(), 14 | 'list' => new TypeHandlers\ListHandler(), 15 | 'default' => new TypeHandlers\DefaultHandler(), 16 | ]; 17 | } 18 | 19 | public function __invoke($content) 20 | { 21 | return $this->build($content); 22 | } 23 | 24 | public function build($content) 25 | { 26 | $content = BlockContent::migrate($content); 27 | $isArray = !isset($content['_type']); 28 | return $isArray 29 | ? $this->parseArray($content) 30 | : $this->parseBlock($content); 31 | } 32 | 33 | public function parseSpans($spans, $parent) 34 | { 35 | $unwantedKeys = array_flip(['_type', 'text', 'marks']); 36 | 37 | $nodeStack = [new Node()]; 38 | 39 | foreach ($spans as $span) { 40 | $attributes = array_diff_key($span, $unwantedKeys); 41 | $marksAsNeeded = $span['marks']; 42 | sort($marksAsNeeded); 43 | 44 | $stackLength = count($nodeStack); 45 | $pos = 1; 46 | 47 | // Start at position one. Root is always plain and should never be removed. (?) 48 | if ($stackLength > 1) { 49 | for (; $pos < $stackLength; $pos++) { 50 | $mark = $nodeStack[$pos]->markKey; 51 | $index = array_search($mark, $marksAsNeeded); 52 | 53 | if ($index === false) { 54 | break; 55 | } 56 | 57 | unset($marksAsNeeded[$index]); 58 | } 59 | } 60 | 61 | // Keep from beginning to first miss 62 | $nodeStack = array_slice($nodeStack, 0, $pos); 63 | 64 | // Add needed nodes 65 | $nodeIndex = count($nodeStack) - 1; 66 | foreach ($marksAsNeeded as $mark) { 67 | $node = new Node([ 68 | 'content' => [], 69 | 'mark' => $this->findMark($mark, $parent), 70 | 'markKey' => $mark, 71 | 'type' => 'span', 72 | ]); 73 | 74 | $nodeStack[$nodeIndex]->addContent($node); 75 | $nodeStack[] = $node; 76 | $nodeIndex++; 77 | } 78 | 79 | if (empty($attributes)) { 80 | $nodeStack[$nodeIndex]->addContent($span['text']); 81 | } else { 82 | $nodeStack[$nodeIndex]->addContent([ 83 | 'type' => 'span', 84 | 'attributes' => $attributes, 85 | 'content' => [$span['text']], 86 | ]); 87 | } 88 | } 89 | 90 | $serialized = $nodeStack[0]->serialize(); 91 | return $serialized['content']; 92 | } 93 | 94 | private function findMark($mark, $parent) 95 | { 96 | $markDefs = isset($parent['markDefs']) ? $parent['markDefs'] : []; 97 | foreach ($markDefs as $markDef) { 98 | if (isset($markDef['_key']) && $markDef['_key'] === $mark) { 99 | return $markDef; 100 | } 101 | } 102 | 103 | return $mark; 104 | } 105 | 106 | public function parseArray($blocks) 107 | { 108 | $parsedData = []; 109 | $listBlocks = []; 110 | foreach ($blocks as $index => $block) { 111 | if (!$this->isList($block)) { 112 | $parsedData[] = $this->parseBlock($block); 113 | continue; 114 | } 115 | 116 | // Each item in a list comes in its own block. 117 | // We bundle these together in a single list object 118 | $listBlocks[] = $block; 119 | $nextBlock = isset($blocks[$index + 1]) ? $blocks[$index + 1] : null; 120 | 121 | // If next block is not a similar list object, this list is complete 122 | if (!isset($nextBlock['listItem']) || ($nextBlock['listItem'] !== $block['listItem'])) { 123 | $parsedData[] = $this->typeHandlers['list']($listBlocks, $this); 124 | $listBlocks = []; 125 | } 126 | } 127 | 128 | return $parsedData; 129 | } 130 | 131 | public function parseBlock($block) 132 | { 133 | $type = $block['_type']; 134 | $typeHandler = isset($this->typeHandlers[$type]) 135 | ? $this->typeHandlers[$type] 136 | : $this->typeHandlers['default']; 137 | 138 | return $typeHandler($block, $this); 139 | } 140 | 141 | public function isList($item) 142 | { 143 | $type = isset($item['_type']) ? $item['_type'] : null; 144 | $listItem = isset($item['listItem']); 145 | return $type === 'block' && $listItem; 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /lib/BlockContent/TypeHandlers/BlockHandler.php: -------------------------------------------------------------------------------- 1 | 'block', 10 | 'style' => isset($block['style']) ? $block['style'] : 'normal', 11 | 'content' => isset($block['children']) ? $treeBuilder->parseSpans($block['children'], $block) : [], 12 | ]; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/BlockContent/TypeHandlers/DefaultHandler.php: -------------------------------------------------------------------------------- 1 | $type, 14 | 'attributes' => $attributes, 15 | ]; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /lib/BlockContent/TypeHandlers/ListHandler.php: -------------------------------------------------------------------------------- 1 | typeHandlers['block']($item, $treeBuilder); 10 | }; 11 | 12 | return [ 13 | 'type' => 'list', 14 | 'itemStyle' => isset($blocks[0]['listItem']) ? $blocks[0]['listItem'] : '', 15 | 'items' => array_map($mapItems, $blocks), 16 | ]; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /lib/Client.php: -------------------------------------------------------------------------------- 1 | 'https://api.sanity.io', 27 | 'apiVersion' => '1', 28 | 'useProjectHostname' => true, 29 | 'timeout' => 30, 30 | 'handler' => null, 31 | ]; 32 | 33 | private $defaultAssetOptions = [ 34 | 'preserveFilename' => true, 35 | 'timeout' => 0, 36 | ]; 37 | 38 | private $clientConfig = []; 39 | private $httpClient; 40 | 41 | /** 42 | * Creates a new instance of the Sanity client 43 | * 44 | * @param array $config Array of configuration options 45 | * @return Client 46 | */ 47 | public function __construct($config = []) 48 | { 49 | $this->config($config); 50 | } 51 | 52 | /** 53 | * Query for documents 54 | * 55 | * Given a GROQ-query and an optional set of parameters, run a query against the API 56 | * and return the decoded result. 57 | * 58 | * @param string $query GROQ-query to send to the API 59 | * @param array $params Associative array of parameters to use for the query 60 | * @param array $options Optional array of options for the query operation 61 | * @return mixed Returns the result - data type depends on the query 62 | */ 63 | public function fetch($query, $params = null, $options = []) 64 | { 65 | $unfilteredResponse = isset($options['filterResponse']) && $options['filterResponse'] === false; 66 | 67 | $serializedParams = $params ? ParameterSerializer::serialize($params) : []; 68 | $queryParams = array_merge(['query' => $query], $serializedParams); 69 | 70 | if (isset($this->clientConfig['perspective']) && $this->clientConfig['perspective'] !== 'raw') { 71 | $queryParams['perspective'] = $this->clientConfig['perspective']; 72 | } 73 | 74 | $body = $this->request([ 75 | 'url' => '/data/query/' . $this->clientConfig['dataset'], 76 | 'query' => $queryParams, 77 | 'cdnAllowed' => true 78 | ]); 79 | 80 | return $unfilteredResponse ? $body : $body['result']; 81 | } 82 | 83 | /** 84 | * Fetch a single document by ID 85 | * 86 | * @param string $id ID of the document to retrieve 87 | * @return array Returns an associative array 88 | */ 89 | public function getDocument($id) 90 | { 91 | $body = $this->request([ 92 | 'url' => '/data/doc/' . $this->clientConfig['dataset'] . '/' . $id, 93 | 'cdnAllowed' => true 94 | ]); 95 | return $body['documents'][0]; 96 | } 97 | 98 | /** 99 | * Create a new document in the configured dataset 100 | * 101 | * @param array $document Document to create. Must include a `_type` 102 | * @param array $options Optional request options 103 | * @return array Returns the created document 104 | */ 105 | public function create($document, $options = null) 106 | { 107 | $this->assertProperties($document, ['_type'], 'create'); 108 | return $this->createDocument($document, 'create', $options); 109 | } 110 | 111 | /** 112 | * Create a new document if the document ID does not already exist 113 | * 114 | * @param array $document Document to create. Must include `_id` and `_type` 115 | * @param array $options Optional request options 116 | * @return array Returns the created document 117 | */ 118 | public function createIfNotExists($document, $options = null) 119 | { 120 | $this->assertProperties($document, ['_id', '_type'], 'createIfNotExists'); 121 | return $this->createDocument($document, 'createIfNotExists', $options); 122 | } 123 | 124 | /** 125 | * Create or replace a document with the given ID 126 | * 127 | * @param array $document Document to create. Must include `_id` and `_type` 128 | * @param array $options Optional request options 129 | * @return array Returns the created document 130 | */ 131 | public function createOrReplace($document, $options = null) 132 | { 133 | $this->assertProperties($document, ['_id', '_type'], 'createOrReplace'); 134 | return $this->createDocument($document, 'createOrReplace', $options); 135 | } 136 | 137 | /** 138 | * Return an instance of Sanity\Patch for the given document ID or query 139 | * 140 | * @param string|array|Selection $selection Document ID or a selection 141 | * @param array $operations Optional array of initial patches 142 | * @return Sanity\Patch 143 | */ 144 | public function patch($selection, $operations = null) 145 | { 146 | return new Patch($selection, $operations, $this); 147 | } 148 | 149 | /** 150 | * Return an instance of Sanity\Transaction bound to the current client 151 | * 152 | * @param array $operations Optional array of initial mutations 153 | * @return Sanity\Transaction 154 | */ 155 | public function transaction($operations = null) 156 | { 157 | return new Transaction($operations, $this); 158 | } 159 | 160 | /** 161 | * Deletes document(s) matching the given document ID or query 162 | * 163 | * @param string|array|Selection $selection Document ID or a selection 164 | * @param array $options Optional array of options for the request 165 | * @return array Returns the mutation result 166 | */ 167 | public function delete($selection, $options = null) 168 | { 169 | $sel = $selection instanceof Selection ? $selection : new Selection($selection); 170 | $opts = array_replace(['returnDocuments' => false], $options ?: []); 171 | return $this->mutate(['delete' => $sel->serialize()], $opts); 172 | } 173 | 174 | /** 175 | * Send a set of mutations to the API for processing 176 | * 177 | * @param array|Patch|Transaction $mutations Either an array of mutations, a patch or a transaction 178 | * @param array $options Optional array of options for the request 179 | * @return array Mutation result 180 | */ 181 | public function mutate($mutations, $options = null) 182 | { 183 | $mut = $mutations; 184 | if ($mut instanceof Patch) { 185 | $mut = ['patch' => $mut->serialize()]; 186 | } elseif ($mut instanceof Transaction) { 187 | $mut = $mut->serialize(); 188 | } 189 | 190 | $body = ['mutations' => !isset($mut[0]) ? [$mut] : $mut]; 191 | $queryParams = $this->getMutationQueryParams($options); 192 | $requestOptions = [ 193 | 'method' => 'POST', 194 | 'query' => $queryParams, 195 | 'headers' => ['Content-Type' => 'application/json'], 196 | 'body' => json_encode($body), 197 | 'url' => '/data/mutate/' . $this->clientConfig['dataset'], 198 | 'cdnAllowed' => false, 199 | ]; 200 | 201 | // Try to perform request 202 | $body = $this->request($requestOptions); 203 | 204 | // Should we return the documents? 205 | $returnDocuments = isset($options['returnDocuments']) && $options['returnDocuments']; 206 | $returnFirst = isset($options['returnFirst']) && $options['returnFirst']; 207 | $results = isset($body['results']) ? $body['results'] : []; 208 | 209 | if ($returnDocuments && $returnFirst) { 210 | return isset($results[0]) ? $results[0]['document'] : null; 211 | } elseif ($returnDocuments) { 212 | return array_column($results, 'document', 'id'); 213 | } 214 | 215 | // Return a reduced subset 216 | if ($returnFirst) { 217 | $ids = isset($results[0]) ? $results[0]['id'] : null; 218 | } else { 219 | $ids = array_column($results, 'id'); 220 | } 221 | 222 | $key = $returnFirst ? 'documentId' : 'documentIds'; 223 | return [ 224 | 'transactionId' => $body['transactionId'], 225 | 'results' => $results, 226 | $key => $ids, 227 | ]; 228 | } 229 | 230 | /** 231 | * Upload an image or a file from a binary string to the configured dataset 232 | * 233 | * Options: 234 | * [ 235 | * preserveFilename (boolean) Whether or not to preserve the original filename (default: true) 236 | * filename (string) Filename for this file (optional, but encouraged) 237 | * timeout (number) Milliseconds to wait before timing the request out (default: 0) 238 | * contentType (string) Mime type of the file 239 | * extract (array) Array of metadata parts to extract from image. 240 | * Possible values: `location`, `exif`, `image`, `palette` 241 | * label (string) Label (deprecated) 242 | * title (string) Title 243 | * description (string) Description 244 | * creditLine (string) The credit to person(s) and/or organization(s) required by the 245 | * supplier of the image to be used when published 246 | * source (array) Source data (when the asset is from an external service) 247 | * source['id'] (string) The (u)id of the asset within the source, i.e. 'i-f323r1E'. 248 | * Required if source is defined. 249 | * source['name'] (string) The name of the source, i.e. 'unsplash'. Required if source is defined. 250 | * source['url'] (string) A url to where to find the asset, or get more info about it in the source. Optional. 251 | * ] 252 | * 253 | * @param string $assetType Either "image" or "file". Images can be transformed with the image API after uploading. 254 | * @param string $data A string containing the binary data of the image or file to upload 255 | * @param array $options Optional assocative array of options for the request 256 | * @return array Asset document 257 | */ 258 | public function uploadAssetFromString($assetType, $data, $options = []) 259 | { 260 | $this->validateAssetType($assetType); 261 | 262 | $assetEndpoint = $assetType === 'image' ? 'images' : 'files'; 263 | $queryParams = []; 264 | 265 | $assetOptions = array_merge($this->defaultAssetOptions, $options); 266 | 267 | // If an empty array is given, explicitly set `none` to override API defaults 268 | if (isset($assetOptions['extract']) && is_array($assetOptions['extract'])) { 269 | $queryParams['meta'] = empty($assetOptions['extract']) ? ['none'] : $assetOptions['extract']; 270 | } 271 | 272 | // Use passed mime type if specified, otherwise default to octet-stream 273 | $mime = isset($assetOptions['contentType']) ? $assetOptions['contentType'] : 'application/octet-stream'; 274 | 275 | // Copy string metadata keys directly to query string if defined 276 | $strMetaKeys = ['label', 'title', 'description', 'creditLine', 'filename']; 277 | foreach ($strMetaKeys as $metaKey) { 278 | if (empty($assetOptions[$metaKey])) { 279 | continue; 280 | } 281 | 282 | if (!is_string($assetOptions[$metaKey])) { 283 | throw new InvalidArgumentException('Asset "' . $metaKey . '" key must be a string if defined'); 284 | } 285 | 286 | $queryParams[$metaKey] = $assetOptions[$metaKey]; 287 | } 288 | 289 | // Validate and set source if defined 290 | if (isset($assetOptions['source']) && is_array($assetOptions['source'])) { 291 | $source = $assetOptions['source']; 292 | if (isset($source['id'])) { 293 | $queryParams['sourceId'] = $source['id']; 294 | } 295 | 296 | if (isset($source['name'])) { 297 | $queryParams['sourceName'] = $source['name']; 298 | } 299 | 300 | if (isset($source['url'])) { 301 | $queryParams['sourceUrl'] = $source['url']; 302 | } 303 | } 304 | 305 | $requestOptions = [ 306 | 'method' => 'POST', 307 | 'timeout' => $assetOptions['timeout'], 308 | 'url' => '/assets/' . $assetEndpoint . '/' . $this->clientConfig['dataset'], 309 | 'headers' => ['Content-Type' => $mime], 310 | 'query' => $queryParams, 311 | 'body' => $data, 312 | 'cdnAllowed' => false, 313 | ]; 314 | 315 | // Try to perform request 316 | $body = $this->request($requestOptions); 317 | return $body['document']; 318 | } 319 | 320 | /** 321 | * Upload an image or a file from a given file path to the configured dataset. 322 | * See `uploadAssetFromString` for explanation of available options. 323 | * 324 | * @param string $assetType Either "image" or "file". Images can be transformed with the image API after uploading. 325 | * @param string $data A string containing the binary data of the image or file to upload 326 | * @param array $options Optional assocative array of options for the request 327 | * @return array Asset document 328 | */ 329 | public function uploadAssetFromFile($assetType, $filePath, $options = []) 330 | { 331 | $this->validateAssetType($assetType); 332 | $this->validateLocalFile($filePath); 333 | 334 | $assetOptions = array_merge($this->defaultAssetOptions, $options); 335 | if ($assetOptions['preserveFilename'] && !isset($assetOptions['filename'])) { 336 | $assetOptions['filename'] = basename($filePath); 337 | } 338 | 339 | return $this->uploadAssetFromString($assetType, file_get_contents($filePath), $assetOptions); 340 | } 341 | 342 | /** 343 | * Sets or gets the client configuration. 344 | * 345 | * If a new configuration is passed as the first argument, it will be merged 346 | * with the existing configuration. If no new configuration is given, the old 347 | * configuration is returned. 348 | * 349 | * @param array|null $newConfig New configuration to use. 350 | * @return array|Client Returns the client instance if a new configuration is passed 351 | */ 352 | public function config($newConfig = null) 353 | { 354 | if ($newConfig === null) { 355 | return $this->clientConfig; 356 | } 357 | 358 | $this->clientConfig = $this->initConfig($newConfig); 359 | 360 | $this->httpClient = new HttpClient([ 361 | 'base_uri' => $this->clientConfig['url'], 362 | 'timeout' => $this->clientConfig['timeout'], 363 | 'handler' => $this->clientConfig['handler'], 364 | ]); 365 | 366 | return $this; 367 | } 368 | 369 | /** 370 | * Performs a request against the Sanity API based on the passed options. 371 | * 372 | * @param array $options Array of options for this request. 373 | * @return mixed Returns a decoded response, type varies with endpoint. 374 | */ 375 | public function request($options) 376 | { 377 | $request = $this->getRequest($options); 378 | $requestOptions = isset($options['query']) ? ['query' => $options['query']] : []; 379 | 380 | // Try to perform request 381 | try { 382 | $response = $this->httpClient->send($request, $requestOptions); 383 | } catch (GuzzleRequestException $err) { 384 | $hasResponse = $err->hasResponse(); 385 | if (!$hasResponse) { 386 | // @todo how do we handle, wrap guzzle err 387 | throw $err; 388 | } 389 | 390 | $response = $err->getResponse(); 391 | $code = $response->getStatusCode(); 392 | 393 | if ($code >= 500) { 394 | throw new ServerException($response); 395 | } elseif ($code >= 400) { 396 | throw new ClientException($response); 397 | } 398 | } 399 | 400 | $warnings = $response->getHeader('X-Sanity-Warning'); 401 | foreach ($warnings as $warning) { 402 | trigger_error($warning, E_USER_WARNING); 403 | } 404 | 405 | $contentType = $response->getHeader('Content-Type')[0]; 406 | $isJson = stripos($contentType, 'application/json') !== false; 407 | $rawBody = (string) $response->getBody(); 408 | $body = $isJson ? json_decode($rawBody, true) : $rawBody; 409 | 410 | return $body; 411 | } 412 | 413 | /** 414 | * Creates a document using the given operation type 415 | * 416 | * @param array $document Document to create 417 | * @param string $operation Operation to use (create/createIfNotExists/createOrReplace) 418 | * @param array $options 419 | * @return array Returns the created document, or the mutation result if returnDocuments is false 420 | */ 421 | private function createDocument($document, $operation, $options = []) 422 | { 423 | $mutation = [$operation => $document]; 424 | $opts = array_replace(['returnFirst' => true, 'returnDocuments' => true], $options ?: []); 425 | return $this->mutate([$mutation], $opts); 426 | } 427 | 428 | /** 429 | * Returns an instance of Request based on the given options and client configuration. 430 | * 431 | * @param array $options Array of options for this request. 432 | * @return GuzzleHttp\Psr7\Request Returns an initialized request. 433 | */ 434 | private function getRequest($options) 435 | { 436 | $headers = isset($options['headers']) ? $options['headers'] : []; 437 | $headers['User-Agent'] = 'sanity-php ' . Version::VERSION; 438 | 439 | if (!empty($this->clientConfig['token'])) { 440 | $headers['Authorization'] = 'Bearer ' . $this->clientConfig['token']; 441 | } 442 | 443 | $method = isset($options['method']) ? $options['method'] : 'GET'; 444 | $body = isset($options['body']) ? $options['body'] : null; 445 | $cdnAllowed = ( 446 | isset($options['cdnAllowed']) && 447 | $options['cdnAllowed'] && 448 | $this->clientConfig['useCdn'] 449 | ); 450 | 451 | $baseUrl = $cdnAllowed 452 | ? $this->clientConfig['cdnUrl'] 453 | : $this->clientConfig['url']; 454 | 455 | $url = $baseUrl . $options['url']; 456 | 457 | return new Request($method, $url, $headers, $body); 458 | } 459 | 460 | /** 461 | * Initialize a new client configuration 462 | * 463 | * Validate a configuration, merging default values and assigning the 464 | * correct hostname based on project ID. 465 | * 466 | * @param array $config New configuration parameters to use. 467 | * @return array Returns the new configuration. 468 | */ 469 | private function initConfig($config) 470 | { 471 | $specifiedConfig = array_replace_recursive($this->clientConfig, $config); 472 | if (!isset($specifiedConfig['apiVersion'])) { 473 | trigger_error(Client::NO_API_VERSION_WARNING, E_USER_DEPRECATED); 474 | } 475 | 476 | $newConfig = array_replace_recursive($this->defaultConfig, $specifiedConfig); 477 | $apiVersion = str_replace('#^v#', '', $newConfig['apiVersion']); 478 | $projectBased = $newConfig['useProjectHostname']; 479 | $useCdn = isset($newConfig['useCdn']) ? $newConfig['useCdn'] : false; 480 | $projectId = isset($newConfig['projectId']) ? $newConfig['projectId'] : null; 481 | $dataset = isset($newConfig['dataset']) ? $newConfig['dataset'] : null; 482 | $perspective = isset($newConfig['perspective']) ? $newConfig['perspective'] : null; 483 | 484 | $apiIsDate = preg_match('#^\d{4}-\d{2}-\d{2}$#', $apiVersion); 485 | if ($apiIsDate) { 486 | try { 487 | new DateTimeImmutable($apiVersion); 488 | } catch (Exception $err) { 489 | throw new ConfigException('Invalid ISO-date "' . $apiVersion . '"'); 490 | } 491 | } elseif ($apiVersion !== 'X' && $apiVersion !== '1') { 492 | throw new ConfigException('Invalid API version, must be either a date in YYYY-MM-DD format, `1` or `X`'); 493 | } 494 | 495 | if ($projectBased && !$projectId) { 496 | throw new ConfigException('Configuration must contain `projectId`'); 497 | } 498 | 499 | if ($projectBased && !$dataset) { 500 | throw new ConfigException('Configuration must contain `dataset`'); 501 | } 502 | 503 | if ($perspective && !is_string($perspective)) { 504 | throw new ConfigException('Configuration `perspective` parameter must be a string'); 505 | } 506 | 507 | $hostParts = explode('://', $newConfig['apiHost']); 508 | $protocol = $hostParts[0]; 509 | $host = $hostParts[1]; 510 | 511 | if ($projectBased) { 512 | $newConfig['url'] = $protocol . '://' . $projectId . '.' . $host . '/v' . $apiVersion; 513 | } else { 514 | $newConfig['url'] = $newConfig['apiHost'] . '/v' . $apiVersion; 515 | } 516 | 517 | $newConfig['useCdn'] = $useCdn; 518 | $newConfig['cdnUrl'] = preg_replace('#(/|\.)api.sanity.io/#', '$1apicdn.sanity.io/', $newConfig['url']); 519 | return $newConfig; 520 | } 521 | 522 | /** 523 | * Get a reduced and normalized set of query params for a mutation based on the given options 524 | * 525 | * @param array $options Array of request options 526 | * @return array Array of normalized query params 527 | */ 528 | private function getMutationQueryParams($options = []) 529 | { 530 | $query = ['returnIds' => 'true']; 531 | 532 | if (!isset($options['returnDocuments']) || $options['returnDocuments']) { 533 | $query['returnDocuments'] = 'true'; 534 | } 535 | 536 | if (isset($options['visibility']) && $options['visibility'] !== 'sync') { 537 | $query['visibility'] = $options['visibility']; 538 | } 539 | 540 | if (isset($options['autoGenerateArrayKeys']) && $options['autoGenerateArrayKeys']) { 541 | $query['autoGenerateArrayKeys'] = 'true'; 542 | } 543 | 544 | return $query; 545 | } 546 | 547 | /** 548 | * Validate whether or not the given file path is valid, exists and is non-empty 549 | * 550 | * @param string $path File path to validate 551 | */ 552 | private function validateLocalFile($path) 553 | { 554 | if (!is_file($path)) { 555 | throw new InvalidArgumentException('File does not exist: ' . $path); 556 | } 557 | 558 | if (!filesize($path)) { 559 | throw new InvalidArgumentException('File is of zero length: ' . $path); 560 | } 561 | } 562 | 563 | /** 564 | * Validate whether or not the given assert type is recognized 565 | * 566 | * @param string $assetType Asset type to validate 567 | */ 568 | private function validateAssetType($assetType) 569 | { 570 | if ($assetType !== 'image' && $assetType !== 'file') { 571 | throw new InvalidArgumentException( 572 | 'Invalid asset type "' . $assetType . '" - should be "image" or "file"' 573 | ); 574 | } 575 | } 576 | } 577 | -------------------------------------------------------------------------------- /lib/Exception/BaseException.php: -------------------------------------------------------------------------------- 1 | getStatusCode(); 13 | $reason = $response->getReasonPhrase(); 14 | $contentType = $response->getHeader('Content-Type')[0]; 15 | $isJson = stripos($contentType, 'application/json') !== false; 16 | $rawBody = (string) $response->getBody(); 17 | $body = $isJson ? json_decode($rawBody, true) : $rawBody; 18 | 19 | $this->response = $response; 20 | $this->statusCode = $code; 21 | $this->responseBody = $rawBody; 22 | 23 | if (isset($body['error']) && isset($body['message'])) { 24 | // API/Boom style errors ({statusCode, error, message}) 25 | $this->message = $body['error'] . ' - ' . $body['message']; 26 | } elseif (isset($body['error']) && isset($body['error']['description'])) { 27 | // Query/database errors ({error: {description, other, arb, props}}) 28 | $this->message = $body['error']['description']; 29 | $this->details = $body['error']; 30 | } else { 31 | // Other, more arbitrary errors 32 | $this->message = $this->resolveErrorMessage($body); 33 | } 34 | 35 | parent::__construct($this->message, $code); 36 | } 37 | 38 | public function getResponse() 39 | { 40 | return $this->response; 41 | } 42 | 43 | public function getResponseBody() 44 | { 45 | return $this->responseBody; 46 | } 47 | 48 | public function getStatusCode() 49 | { 50 | return $this->statusCode; 51 | } 52 | 53 | private function resolveErrorMessage($body) 54 | { 55 | if (isset($body['error'])) { 56 | return $body['error']; 57 | } 58 | 59 | if (isset($body['message'])) { 60 | return $body['message']; 61 | } 62 | 63 | return 'Unknown error; body: ' . json_encode($body); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /lib/Exception/ServerException.php: -------------------------------------------------------------------------------- 1 | $value) { 10 | $serialized['$' . $key] = json_encode($value); 11 | } 12 | return $serialized; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /lib/Patch.php: -------------------------------------------------------------------------------- 1 | client = $client; 19 | $this->operations = $operations; 20 | 21 | $this->selection = $selection instanceof Selection 22 | ? $selection 23 | : new Selection($selection); 24 | } 25 | 26 | public function merge($props) 27 | { 28 | $previous = isset($this->operations['merge']) ? $this->operations['merge'] : []; 29 | $this->operations['merge'] = array_replace_recursive($previous, $props); 30 | return $this; 31 | } 32 | 33 | public function set($props) 34 | { 35 | return $this->assign('set', $props); 36 | } 37 | 38 | public function setIfMissing($props) 39 | { 40 | return $this->assign('setIfMissing', $props); 41 | } 42 | 43 | public function diffMatchPatch($props) 44 | { 45 | return $this->assign('diffMatchPatch', $props); 46 | } 47 | 48 | public function remove($attrs) 49 | { 50 | if (!is_array($attrs)) { 51 | throw new InvalidArgumentException( 52 | 'remove(attrs) takes an array of attributes to unset, non-array given' 53 | ); 54 | } 55 | 56 | $previous = isset($this->operations['unset']) ? $this->operations['unset'] : []; 57 | $merged = array_unique(array_merge($previous, $attrs)); 58 | $this->operations['unset'] = $merged; 59 | return $this; 60 | } 61 | 62 | public function replace($props) 63 | { 64 | $this->operations['set'] = ['$' => $props]; 65 | return $this; 66 | } 67 | 68 | private function assign($operation, $props, $merge = true) 69 | { 70 | $previous = isset($this->operations[$operation]) ? $this->operations[$operation] : []; 71 | $this->operations[$operation] = $merge ? array_replace($previous, $props) : $props; 72 | return $this; 73 | } 74 | 75 | public function inc($props) 76 | { 77 | return $this->assign('inc', $props); 78 | } 79 | 80 | public function dec($props) 81 | { 82 | return $this->assign('dec', $props); 83 | } 84 | 85 | public function insert($at, $selector, $items) 86 | { 87 | $this->validateInsert($at, $selector, $items); 88 | return $this->assign('insert', [$at => $selector, 'items' => $items]); 89 | } 90 | 91 | public function append($selector, $items) 92 | { 93 | return $this->insert('after', $selector . '[-1]', $items); 94 | } 95 | 96 | public function prepend($selector, $items) 97 | { 98 | return $this->insert('before', $selector . '[0]', $items); 99 | } 100 | 101 | public function splice($selector, $start, $deleteCount = null, $items = null) 102 | { 103 | // Negative indexes doesn't mean the same in Sanity as they do in PHP; 104 | // -1 means "actually at the end of the array", which allows inserting 105 | // at the end of the array without knowing its length. We therefore have 106 | // to substract negative indexes by one to match PHP. If you want Sanity- 107 | // behaviour, just use `insert('replace', selector, items)` directly 108 | $delAll = $deleteCount === null || $deleteCount === -1; 109 | $startIndex = $start < 0 ? $start - 1 : $start; 110 | $delCount = $delAll ? -1 : max(0, $start + $deleteCount); 111 | $delRange = $startIndex < 0 && $delCount >= 0 ? '' : $delCount; 112 | $rangeSelector = $selector . '[' . $startIndex . ':' . $delRange . ']'; 113 | return $this->insert('replace', $rangeSelector, $items ?: []); 114 | } 115 | 116 | public function serialize() 117 | { 118 | return array_replace( 119 | $this->selection->serialize(), 120 | $this->operations 121 | ); 122 | } 123 | 124 | public function jsonSerialize() 125 | { 126 | return $this->serialize(); 127 | } 128 | 129 | public function commit($options = null) 130 | { 131 | if (!$this->client) { 132 | throw new Exception\ConfigException( 133 | 'No "client" passed to patch, either provide one or pass the patch to a clients mutate() method' 134 | ); 135 | } 136 | 137 | $returnFirst = $this->selection->matchesMultiple() === false; 138 | $opts = array_replace(['returnFirst' => $returnFirst, 'returnDocuments' => true], $options ?: []); 139 | return $this->client->mutate(['patch' => $this->serialize()], $opts); 140 | } 141 | 142 | public function reset() 143 | { 144 | $this->operations = []; 145 | return $this; 146 | } 147 | 148 | /** 149 | * Validates parameters for an insert operation 150 | * 151 | * @param string $at Location at which to insert 152 | * @param string $selector Selector to match at 153 | * @param array $items Array items to insert 154 | * @throws InvalidArgumentException 155 | */ 156 | private function validateInsert($at, $selector, $items) 157 | { 158 | $insertLocations = ['before', 'after', 'replace']; 159 | $signature = 'insert(at, selector, items)'; 160 | 161 | $index = array_search($at, $insertLocations); 162 | if ($index === false) { 163 | $valid = implode(', ', array_map(function ($loc) { 164 | return '"' . $loc . '"'; 165 | }, $insertLocations)); 166 | throw new InvalidArgumentException($signature . ' takes an "at"-argument which is one of: ' . $valid); 167 | } 168 | 169 | if (!is_string($selector)) { 170 | throw new InvalidArgumentException($signature . ' takes a "selector"-argument which must be a string'); 171 | } 172 | 173 | if (!is_array($items)) { 174 | throw new InvalidArgumentException($signature . ' takes an "items"-argument which must be an array'); 175 | } 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /lib/Selection.php: -------------------------------------------------------------------------------- 1 | selection = $this->normalize($selection); 19 | } 20 | 21 | /** 22 | * Serializes the selection for use in requests 23 | * 24 | * @return array 25 | */ 26 | public function jsonSerialize() 27 | { 28 | return $this->serialize(); 29 | } 30 | 31 | /** 32 | * Serializes the selection for use in requests 33 | * 34 | * @return array 35 | */ 36 | public function serialize() 37 | { 38 | return $this->selection; 39 | } 40 | 41 | /** 42 | * Returns whether or not the selection *can* match multiple documents 43 | * 44 | * @return bool 45 | */ 46 | public function matchesMultiple() 47 | { 48 | return !isset($this->selection['id']) || is_array($this->selection['id']); 49 | } 50 | 51 | /** 52 | * Validates and normalizes a selection 53 | * 54 | * @return array 55 | * @throws InvalidArgumentException 56 | */ 57 | private function normalize($selection) 58 | { 59 | if (isset($selection['query'])) { 60 | return ['query' => $selection['query']]; 61 | } 62 | 63 | if (is_string($selection) || (is_array($selection) && isset($selection[0]))) { 64 | return ['id' => $selection]; 65 | } 66 | 67 | $selectionOpts = implode(PHP_EOL, [ 68 | '', 69 | '* Document ID ()', 70 | '* Array of document IDs', 71 | '* Array containing "query"', 72 | ]); 73 | 74 | throw new InvalidArgumentException('Invalid selection, must be one of: ' . $selectionOpts); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /lib/Transaction.php: -------------------------------------------------------------------------------- 1 | operations = $operations; 18 | $this->client = $client; 19 | } 20 | 21 | /** 22 | * Create a new document in the configured dataset 23 | * 24 | * @param array $document Document to create. Must include a `_type` 25 | * @return array Returns the transaction 26 | */ 27 | public function create($document) 28 | { 29 | $this->assertProperties($document, ['_type'], 'create'); 30 | $this->operations[] = ['create' => $document]; 31 | return $this; 32 | } 33 | 34 | /** 35 | * Create a new document if the document ID does not already exist 36 | * 37 | * @param array $document Document to create. Must include `_id` and `_type` 38 | * @return array Returns the transaction 39 | */ 40 | public function createIfNotExists($document) 41 | { 42 | $this->assertProperties($document, ['_id', '_type'], 'createIfNotExists'); 43 | $this->operations[] = ['createIfNotExists' => $document]; 44 | return $this; 45 | } 46 | 47 | /** 48 | * Create or replace a document with the given ID 49 | * 50 | * @param array $document Document to create or replace. Must include `_id` and `_type` 51 | * @return array Returns the transaction 52 | */ 53 | public function createOrReplace($document) 54 | { 55 | $this->assertProperties($document, ['_id', '_type'], 'createOrReplace'); 56 | $this->operations[] = ['createOrReplace' => $document]; 57 | return $this; 58 | } 59 | 60 | /** 61 | * Deletes document(s) matching the given document ID or query 62 | * 63 | * @param string|array|Selection $selection Document ID or a selection 64 | * @return array Returns the transaction 65 | */ 66 | public function delete($selection) 67 | { 68 | $sel = $selection instanceof Selection ? $selection : new Selection($selection); 69 | $this->operations[] = ['delete' => $sel->serialize()]; 70 | return $this; 71 | } 72 | 73 | /** 74 | * Patch the given document or selection 75 | * 76 | * If a patch is passed as the first argument, it will be used directly. 77 | * 78 | * @param string|array|Selection|Patch $selection Document ID, selection or a patch to apply 79 | * @param array $operations Optional array of initial patches 80 | * @return Sanity\Patch 81 | */ 82 | public function patch($selection, $operations = null) 83 | { 84 | $isPatch = $selection instanceof Patch; 85 | $patch = $isPatch ? $selection : null; 86 | 87 | if ($isPatch) { 88 | $this->operations[] = ['patch' => $patch->serialize()]; 89 | return $this; 90 | } 91 | 92 | if (!is_array($operations)) { 93 | throw new InvalidArgumentException( 94 | '`patch` requires either an instantiated patch or an array of patch operations' 95 | ); 96 | } 97 | 98 | $sel = (new Selection($selection))->serialize(); 99 | $this->operations[] = ['patch' => array_merge($sel, $operations)]; 100 | return $this; 101 | } 102 | 103 | public function serialize() 104 | { 105 | return $this->operations; 106 | } 107 | 108 | public function jsonSerialize() 109 | { 110 | return $this->serialize(); 111 | } 112 | 113 | public function commit($options = null) 114 | { 115 | if (!$this->client) { 116 | throw new Exception\ConfigException( 117 | 'No "client" passed to transaction, either provide one or ' 118 | . 'pass the transaction to a clients `mutate()` method' 119 | ); 120 | } 121 | 122 | $opts = array_replace(['returnDocuments' => false], $options ?: []); 123 | return $this->client->mutate($this->serialize(), $opts); 124 | } 125 | 126 | public function reset() 127 | { 128 | $this->operations = []; 129 | return $this; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /lib/Util/DocumentPropertyAsserter.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | The coding standard Sanity PHP projects. 4 | 5 | 6 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | test 5 | 6 | 7 | 8 | 9 | lib 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "github>sanity-io/renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /test/BlockContentHtmlTest.php: -------------------------------------------------------------------------------- 1 | function ($author) { 21 | return '
    ' . $author['attributes']['name'] . '
    '; 22 | }, 23 | 'block' => function ($block) { 24 | return $block['style'] === 'h2' 25 | ? '
    ' . implode('', $block['children']) . '
    ' 26 | : '

    ' . implode('', $block['children']) . '

    '; 27 | }, 28 | 'list' => function($list) { 29 | $style = isset($list['itemStyle']) ? $list['itemStyle'] : 'default'; 30 | $tagName = $style === 'number' ? 'ol' : 'ul'; 31 | return '<' . $tagName . ' class="foo">' . implode('', $list['children']) . ''; 32 | }, 33 | 'listItem' => function($item) { 34 | return '
  • ' . implode('', $item['children']) . '
  • '; 35 | }, 36 | 37 | 'marks' => [ 38 | 'em' => null, 39 | 'author' => function ($mark, $children) { 40 | return '
    ' . $mark['name'] . '
    ' . implode('', $children); 41 | }, 42 | 'link' => [ 43 | 'head' => function ($mark) { 44 | return ''; 45 | }, 46 | 'tail' => '' 47 | ] 48 | ] 49 | ]; 50 | $this->customHtmlBuilder = new HtmlBuilder(['serializers' => $serializers]); 51 | $this->htmlBuilder = new HtmlBuilder([ 52 | 'projectId' => 'abc123', 53 | 'dataset' => 'prod', 54 | 'imageOptions' => ['fit' => 'crop', 'w' => 320, 'h' => 240] 55 | ]); 56 | } 57 | 58 | public function testHandlesPlainStringBlock() 59 | { 60 | $input = BlockContent::toTree($this->loadFixture('normal-text.json')); 61 | $expected = '

    Normal string of text.

    '; 62 | $actual = $this->htmlBuilder->build($input); 63 | $this->assertEquals($expected, $actual); 64 | } 65 | 66 | public function testHandlesPlainStringBlockWithCustomSerializer() 67 | { 68 | $input = BlockContent::toTree($this->loadFixture('normal-text.json')); 69 | $expected = '

    Normal string of text.

    '; 70 | $actual = $this->customHtmlBuilder->build($input); 71 | $this->assertEquals($expected, $actual); 72 | } 73 | 74 | public function testHandlesItalicizedText() 75 | { 76 | $input = BlockContent::toTree($this->loadFixture('italicized-text.json')); 77 | $expected = '

    String with an italicized word.

    '; 78 | $actual = $this->htmlBuilder->build($input); 79 | $this->assertEquals($expected, $actual); 80 | } 81 | 82 | public function testHandlesItalicizedTextCustomHandlerRemovesEmMarkIfMappedToNull() 83 | { 84 | $input = BlockContent::toTree($this->loadFixture('italicized-text.json')); 85 | $expected = '

    String with an italicized word.

    '; 86 | $actual = $this->customHtmlBuilder->build($input); 87 | $this->assertEquals($expected, $actual); 88 | } 89 | 90 | public function testHandlesUnderlinedText() 91 | { 92 | $input = BlockContent::toTree($this->loadFixture('underlined-text.json')); 93 | $expected = '

    String with an underlined word.

    '; 94 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 95 | } 96 | 97 | public function testHandlesBoldUnderlinedText() 98 | { 99 | $input = BlockContent::toTree($this->loadFixture('bold-underline-text.json')); 100 | $expected = '

    Normalonly-boldbold-and-underlineonly-underlinenormal

    '; 101 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 102 | } 103 | 104 | public function testDoesNotCareAboutSpanMarksOrder() 105 | { 106 | $orderedInput = BlockContent::toTree($this->loadFixture('marks-ordered-text.json')); 107 | $reorderedInput = BlockContent::toTree($this->loadFixture('marks-reordered-text.json')); 108 | $expected = '

    Normalstrongstrong and underlinestrong and underline and emphasis' 109 | . 'underline and emphasisnormal again

    '; 110 | $this->assertEquals($expected, $this->htmlBuilder->build($orderedInput)); 111 | $this->assertEquals($expected, $this->htmlBuilder->build($reorderedInput)); 112 | } 113 | 114 | 115 | public function testHandlesMessyText() 116 | { 117 | $input = BlockContent::toTree($this->loadFixture('messy-text.json')); 118 | $expected = '

    Hacking teh codez is all fun' 119 | . ' and games until someone gets p0wn3d.

    '; 120 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 121 | } 122 | 123 | public function testHandlesSimpleLinkText() 124 | { 125 | $input = BlockContent::toTree($this->loadFixture('link-simple-text.json')); 126 | $expected = '

    String before link actual link text the rest

    '; 127 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 128 | } 129 | 130 | public function testHandlesSimpleLinkTextWithCustomSerializer() 131 | { 132 | $input = BlockContent::toTree($this->loadFixture('link-simple-text.json')); 133 | $expected = '

    String before link actual link text the rest

    '; 134 | $this->assertEquals($expected, $this->customHtmlBuilder->build($input)); 135 | } 136 | 137 | public function testHandlesSimpleLinkTextWithSeveralAttributesWithCustomSerializer() 138 | { 139 | $input = BlockContent::toTree($this->loadFixture('link-author-text.json')); 140 | $expected = '

    String before link ' 141 | . '

    Test Testesen
    actual link text the rest

    '; 142 | $this->assertEquals($expected, $this->customHtmlBuilder->build($input)); 143 | } 144 | 145 | public function testHandlesImages() 146 | { 147 | $input = BlockContent::toTree($this->loadFixture('images.json')); 148 | $expected = '

    Also, images are pretty common.

    ' 149 | . '
    '; 150 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 151 | } 152 | 153 | public function testAllowsOverridingImageSerializer() 154 | { 155 | $htmlBuilder = new HtmlBuilder([ 156 | 'serializers' => ['image' => new MyCustomImageSerializer()], 157 | 'projectId' => 'abc123', 158 | 'dataset' => 'prod', 159 | 'imageOptions' => ['fit' => 'crop', 'w' => 320, 'h' => 240] 160 | ]); 161 | 162 | $input = BlockContent::toTree($this->loadFixture('image-with-caption.json')); 163 | $url = 'https://cdn.sanity.io/images/abc123/prod/YiOKD0O6AdjKPaK24WtbOEv0-3456x2304.jpg?fit=crop&w=320&h=240'; 164 | $expected = '

    Also, images are pretty common.

    ' 165 | . '
    ' 166 | . '
    Now ==> THIS <== is a caption
    ' 167 | . '
    '; 168 | $this->assertEquals($expected, $htmlBuilder->build($input)); 169 | } 170 | 171 | public function testHandlesMessyLinkText() 172 | { 173 | $input = BlockContent::toTree($this->loadFixture('link-messy-text.json')); 174 | $expected = '

    String with link to internet is very strong and emphasis and just emphasis.

    '; 175 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 176 | } 177 | 178 | public function testHandlesMessyLinkTextWithNewStructure() 179 | { 180 | $input = BlockContent::toTree($this->loadFixture('link-messy-text-new.json')); 181 | $expected = '

    String with link to internet is very strong and emphasis and just emphasis.

    '; 182 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 183 | } 184 | 185 | public function testHandlesNumberedList() 186 | { 187 | $input = BlockContent::toTree($this->loadFixture('list-numbered-blocks.json')); 188 | $expected = '
    1. One

    2. Two has bold word

    3. Three

    '; 189 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 190 | } 191 | 192 | public function testHandlesNumberedListWithCustomSerializer() 193 | { 194 | $input = BlockContent::toTree($this->loadFixture('list-numbered-blocks.json')); 195 | $expected = '
    1. One

    2. ' 196 | . '
    3. Two has bold word

    4. ' 197 | . '
    5. Three
    '; 198 | $this->assertEquals($expected, $this->customHtmlBuilder->build($input)); 199 | } 200 | 201 | 202 | public function testHandlesBulletedList() 203 | { 204 | $input = BlockContent::toTree($this->loadFixture('list-bulleted-blocks.json')); 205 | $expected = ''; 207 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 208 | } 209 | 210 | public function testHandlesMultipleLists() 211 | { 212 | $input = BlockContent::toTree($this->loadFixture('list-both-types-blocks.json')); 213 | $expected = '' 214 | . '
    1. First numbered

    2. Second numbered

    ' 215 | . ''; 216 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 217 | } 218 | 219 | public function testHandlesPlainH2Block() 220 | { 221 | $input = BlockContent::toTree($this->loadFixture('h2-text.json')); 222 | $expected = '

    Such h2 header, much amaze

    '; 223 | $this->assertEquals($expected, $this->htmlBuilder->build($input)); 224 | } 225 | 226 | public function testHandlesPlainH2BlockWithCustomSerializer() 227 | { 228 | $input = BlockContent::toTree($this->loadFixture('h2-text.json')); 229 | $expected = '
    Such h2 header, much amaze
    '; 230 | $this->assertEquals($expected, $this->customHtmlBuilder->build($input)); 231 | } 232 | 233 | /** 234 | * @expectedException Sanity\Exception\ConfigException 235 | * @expectedExceptionMessage No serializer registered for node type "author" 236 | */ 237 | public function testThrowsErrorOnCustomBlockTypeWithoutRegisteredHandler() 238 | { 239 | $input = BlockContent::toTree($this->loadFixture('custom-block.json')); 240 | $this->htmlBuilder->build($input); 241 | } 242 | 243 | public function testHandlesCustomBlockTypeWithCustomRegisteredHandler() 244 | { 245 | $input = BlockContent::toTree($this->loadFixture('custom-block.json')); 246 | $expected = '
    Test Person
    '; 247 | $actual = $this->customHtmlBuilder->build($input); 248 | $this->assertEquals($expected, $actual); 249 | } 250 | 251 | public function testEscapesHtmlCharacters() 252 | { 253 | $input = BlockContent::toTree($this->loadFixture('dangerous-text.json')); 254 | $expected = '

    I am 1337 <script>alert('//haxxor');</script>

    '; 255 | $actual = $this->htmlBuilder->build($input); 256 | $this->assertEquals($expected, $actual); 257 | } 258 | 259 | public function testEscapesCharactersInNonUnicodeCharsets() 260 | { 261 | $input = BlockContent::toTree($this->loadFixture('dangerous-text.json')); 262 | $expected = '

    I am 1337 <script>alert('//haxxor');</script>

    '; 263 | $actual = BlockContent::toHtml($input, ['charset' => 'iso-8859-1']); 264 | $this->assertEquals($expected, $actual); 265 | } 266 | 267 | public function testEscapesCharactersForCharsetsThatNeedsConversionToUnicode() 268 | { 269 | $input = BlockContent::toTree($this->loadFixture('dangerous-text.json')); 270 | $expected = '

    I am 1337 <script>alert('//haxxor');</script>

    '; 271 | $actual = BlockContent::toHtml($input, ['charset' => 'ASCII']); 272 | $this->assertEquals($expected, $actual); 273 | } 274 | 275 | public function testCanBeCalledStaticallyWithArray() 276 | { 277 | $expected = '

    Hacking teh codez is all fun' 278 | . ' and games until someone gets p0wn3d.

    '; 279 | $this->assertEquals($expected, BlockContent::toHtml($this->loadFixture('messy-text.json'))); 280 | } 281 | 282 | public function testCanBeCalledStaticallyWithTree() 283 | { 284 | $expected = '

    Hacking teh codez is all fun' 285 | . ' and games until someone gets p0wn3d.

    '; 286 | $tree = BlockContent::toTree($this->loadFixture('messy-text.json')); 287 | $this->assertEquals($expected, BlockContent::toHtml($tree)); 288 | } 289 | 290 | public function testCanBeCalledAsFunction() 291 | { 292 | $input = BlockContent::toTree($this->loadFixture('link-simple-text.json')); 293 | $expected = '

    String before link actual link text the rest

    '; 294 | $htmlBuilder = $this->htmlBuilder; 295 | $this->assertEquals($expected, $htmlBuilder($input)); 296 | } 297 | 298 | public function testCanSerializeDocument() { 299 | $input = $this->loadFixture('document.json')['body']; 300 | $expected = '

    Integer leo sapien, aliquet nec sodales et, fermentum id arcu. Sed vitae fermentum erat. Cras vitae fermentum neque. Nunc condimentum justo ut est rutrum, nec varius lectus aliquam. Nunc vulputate nunc scelerisque, pulvinar odio quis, pulvinar tortor. Mauris iaculis enim non nibh condimentum bibendum. Proin imperdiet ligula sed neque laoreet gravida. Proin non lorem a leo venenatis efficitur sit amet et arcu. Suspendisse potenti. Praesent tempus vitae elit vel blandit. Vestibulum sollicitudin metus vel urna sollicitudin egestas.

    Maecenas massa dui, pretium ac quam sed, euismod viverra risus. Nam vehicula, libero eget tincidunt ullamcorper, nibh mauris auctor ex, quis vulputate felis massa ac libero. Praesent eget auctor justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas nec purus vel magna pellentesque aliquet. Vestibulum sit amet enim nec nulla tempus maximus. Proin maximus elementum maximus. Pellentesque quis interdum nisl.

    '; 301 | $this->assertEquals($expected, BlockContent::toHtml($input)); 302 | } 303 | } 304 | -------------------------------------------------------------------------------- /test/BlockContentMigrationTest.php: -------------------------------------------------------------------------------- 1 | loadFixture('bold-underline-text.json'); 15 | 16 | $expected = [ 17 | '_type' => 'block', 18 | 'style' => 'normal', 19 | 'markDefs' => [], 20 | 'children' => [ 21 | [ 22 | '_type' => 'span', 23 | 'text' => 'Normal', 24 | 'marks' => [] 25 | ], 26 | [ 27 | '_type' => 'span', 28 | 'text' => 'only-bold', 29 | 'marks' => ['strong'] 30 | ], 31 | [ 32 | '_type' => 'span', 33 | 'text' => 'bold-and-underline', 34 | 'marks' => ['strong', 'underline'] 35 | ], 36 | [ 37 | '_type' => 'span', 38 | 'text' => 'only-underline', 39 | 'marks' => ['underline'] 40 | ], 41 | [ 42 | '_type' => 'span', 43 | 'text' => 'normal', 44 | 'marks' => [] 45 | ], 46 | ] 47 | ]; 48 | 49 | $actual = BlockContent::migrateBlock($input); 50 | $this->assertEquals($expected, $actual); 51 | } 52 | 53 | public function testMigrateLinkToV2() 54 | { 55 | $input = $this->loadFixture('link-simple-text.json'); 56 | 57 | $expected = [ 58 | '_type' => 'block', 59 | 'style' => 'normal', 60 | 'markDefs' => [ 61 | [ 62 | '_key' => '6721bbe', 63 | '_type' => 'link', 64 | 'href' => 'http://icanhas.cheezburger.com/' 65 | ] 66 | ], 67 | 'children' => [ 68 | [ 69 | '_type' => 'span', 70 | 'text' => 'String before link ', 71 | 'marks' => [] 72 | ], 73 | [ 74 | '_type' => 'span', 75 | 'text' => 'actual link text', 76 | 'marks' => ['6721bbe'] 77 | ], 78 | [ 79 | '_type' => 'span', 80 | 'text' => ' the rest', 81 | 'marks' => [] 82 | ], 83 | ] 84 | ]; 85 | 86 | $actual = BlockContent::migrateBlock($input); 87 | $this->assertEquals($expected, $actual); 88 | } 89 | 90 | public function testMigrateCustomMarkToV2() 91 | { 92 | $input = $this->loadFixture('link-author-text.json'); 93 | 94 | $expected = [ 95 | '_type' => 'block', 96 | 'style' => 'normal', 97 | 'markDefs' => [ 98 | [ 99 | '_key' => '6721bbe', 100 | '_type' => 'link', 101 | 'href' => 'http://icanhas.cheezburger.com/' 102 | ], 103 | [ 104 | '_key' => 'a0cc21d', 105 | '_type' => 'author', 106 | 'name' => 'Test Testesen' 107 | ] 108 | ], 109 | 'children' => [ 110 | [ 111 | '_type' => 'span', 112 | 'text' => 'String before link ', 113 | 'marks' => [] 114 | ], 115 | [ 116 | '_type' => 'span', 117 | 'text' => 'actual link text', 118 | 'marks' => ['6721bbe', 'a0cc21d'] 119 | ], 120 | [ 121 | '_type' => 'span', 122 | 'text' => ' the rest', 123 | 'marks' => [] 124 | ], 125 | ] 126 | ]; 127 | 128 | $actual = BlockContent::migrate($input); 129 | $this->assertEquals($expected, $actual); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /test/BlockContentTreeTest.php: -------------------------------------------------------------------------------- 1 | loadFixture('normal-text.json'); 16 | $expected = [ 17 | 'type' => 'block', 18 | 'style' => 'normal', 19 | 'content' => [ 20 | 'Normal string of text.', 21 | ] 22 | ]; 23 | 24 | $actual = BlockContent::ToTree($input); 25 | $this->assertEquals($expected, $actual); 26 | } 27 | 28 | public function testHandlesItalicizedText() 29 | { 30 | $input = $this->loadFixture('italicized-text.json'); 31 | $expected = [ 32 | 'type' => 'block', 33 | 'style' => 'normal', 34 | 'content' => [ 35 | 'String with an ', 36 | [ 37 | 'type' => 'span', 38 | 'mark' => 'em', 39 | 'content' => [ 40 | 'italicized' 41 | ] 42 | ], 43 | ' word.' 44 | ] 45 | ]; 46 | $actual = BlockContent::ToTree($input); 47 | $this->assertEquals($expected, $actual); 48 | } 49 | 50 | public function testHandlesUnderlineText() 51 | { 52 | $input = $this->loadFixture('underlined-text.json'); 53 | $expected = [ 54 | 'type' => 'block', 55 | 'style' => 'normal', 56 | 'content' => [ 57 | 'String with an ', 58 | [ 59 | 'type' => 'span', 60 | 'mark' => 'underline', 61 | 'content' => [ 62 | 'underlined' 63 | ] 64 | ], 65 | ' word.' 66 | ] 67 | ]; 68 | $actual = BlockContent::ToTree($input); 69 | $this->assertEquals($expected, $actual); 70 | } 71 | 72 | public function testHandlesBoldUnderlineText() 73 | { 74 | $input = $this->loadFixture('bold-underline-text.json'); 75 | $expected = [ 76 | 'type' => 'block', 77 | 'style' => 'normal', 78 | 'content' => [ 79 | 'Normal', 80 | [ 81 | 'type' => 'span', 82 | 'mark' => 'strong', 83 | 'content' => [ 84 | 'only-bold', 85 | [ 86 | 'type' => 'span', 87 | 'mark' => 'underline', 88 | 'content' => [ 89 | 'bold-and-underline' 90 | ] 91 | ] 92 | ] 93 | ], 94 | [ 95 | 'type' => 'span', 96 | 'mark' => 'underline', 97 | 'content' => [ 98 | 'only-underline' 99 | ] 100 | ], 101 | 'normal' 102 | ] 103 | ]; 104 | $actual = BlockContent::ToTree($input); 105 | $this->assertEquals($expected, $actual); 106 | } 107 | 108 | public function testDoesNotCareAboutSpanMarksOrder() 109 | { 110 | $orderedInput = $this->loadFixture('marks-ordered-text.json'); 111 | $reorderedInput = $this->loadFixture('marks-reordered-text.json'); 112 | $expected = [ 113 | 'type' => 'block', 114 | 'style' => 'normal', 115 | 'content' => [ 116 | 'Normal', 117 | [ 118 | 'type' => 'span', 119 | 'mark' => 'strong', 120 | 'content' => [ 121 | 'strong', 122 | [ 123 | 'type' => 'span', 124 | 'mark' => 'underline', 125 | 'content' => [ 126 | 'strong and underline', 127 | [ 128 | 'type' => 'span', 129 | 'mark' => 'em', 130 | 'content' => [ 131 | 'strong and underline and emphasis' 132 | ] 133 | ] 134 | ] 135 | ] 136 | ] 137 | ], 138 | [ 139 | 'type' => 'span', 140 | 'mark' => 'em', 141 | 'content' => [ 142 | [ 143 | 'type' => 'span', 144 | 'mark' => 'underline', 145 | 'content' => [ 146 | 'underline and emphasis' 147 | ] 148 | ] 149 | ] 150 | ], 151 | 'normal again' 152 | ] 153 | ]; 154 | $this->assertEquals($expected, BlockContent::ToTree($orderedInput)); 155 | $this->assertEquals($expected, BlockContent::ToTree($reorderedInput)); 156 | } 157 | 158 | 159 | public function testHandlesMessyText() 160 | { 161 | $input = $this->loadFixture('messy-text.json'); 162 | $expected = [ 163 | 'type' => 'block', 164 | 'style' => 'normal', 165 | 'content' => [ 166 | 'Hacking ', 167 | [ 168 | 'type' => 'span', 169 | 'mark' => 'code', 170 | 'content' => [ 171 | 'teh codez' 172 | ] 173 | ], 174 | ' is ', 175 | [ 176 | 'type' => 'span', 177 | 'mark' => 'strong', 178 | 'content' => [ 179 | 'all ', 180 | [ 181 | 'type' => 'span', 182 | 'mark' => 'underline', 183 | 'content' => [ 184 | 'fun' 185 | ] 186 | ], 187 | ' and ', 188 | [ 189 | 'type' => 'span', 190 | 'mark' => 'em', 191 | 'content' => [ 192 | 'games' 193 | ] 194 | ], 195 | ' until' 196 | ] 197 | ], 198 | ' someone gets p0wn3d.' 199 | ] 200 | ]; 201 | $actual = BlockContent::ToTree($input); 202 | $this->assertEquals($expected, $actual); 203 | } 204 | 205 | public function testHandlesSimpleLinkText() 206 | { 207 | $input = $this->loadFixture('link-simple-text.json'); 208 | $expected = [ 209 | 'type' => 'block', 210 | 'style' => 'normal', 211 | 'content' => [ 212 | 'String before link ', 213 | [ 214 | 'type' => 'span', 215 | 'mark' => [ 216 | '_type' => 'link', 217 | '_key' => '6721bbe', 218 | 'href' => 'http://icanhas.cheezburger.com/' 219 | ], 220 | 'content' => [ 221 | 'actual link text' 222 | ] 223 | ], 224 | ' the rest' 225 | ] 226 | ]; 227 | $actual = BlockContent::ToTree($input); 228 | $this->assertEquals($expected, $actual); 229 | } 230 | 231 | public function testHandlesMessyLinkText() 232 | { 233 | $input = $this->loadFixture('link-messy-text.json'); 234 | $expected = [ 235 | 'type' => 'block', 236 | 'style' => 'normal', 237 | 'content' => [ 238 | 'String with link to ', 239 | [ 240 | 'type' => 'span', 241 | 'mark' => [ 242 | '_type' => 'link', 243 | '_key' => '6721bbe', 244 | 'href' => 'http://icanhas.cheezburger.com/' 245 | ], 246 | 'content' => [ 247 | 'internet ', 248 | [ 249 | 'type' => 'span', 250 | 'mark' => 'em', 251 | 'content' => [ 252 | [ 253 | 'type' => 'span', 254 | 'mark' => 'strong', 255 | 'content' => [ 256 | 'is very strong and emphasis' 257 | ] 258 | ], 259 | ' and just emphasis' 260 | ] 261 | ] 262 | ] 263 | ], 264 | '.' 265 | ] 266 | ]; 267 | $actual = BlockContent::ToTree($input); 268 | $this->assertEquals($expected, $actual); 269 | } 270 | 271 | public function testHandlesMessyLinkTextWithNewStructure() 272 | { 273 | $input = $this->loadFixture('link-messy-text-new.json'); 274 | $expected = [ 275 | 'type' => 'block', 276 | 'style' => 'normal', 277 | 'content' => [ 278 | 'String with link to ', 279 | [ 280 | 'type' => 'span', 281 | 'mark' => [ 282 | '_type' => 'link', 283 | '_key' => 'zomgLink', 284 | 'href' => 'http://icanhas.cheezburger.com/' 285 | ], 286 | 'content' => [ 287 | 'internet ', 288 | [ 289 | 'type' => 'span', 290 | 'mark' => 'em', 291 | 'content' => [ 292 | [ 293 | 'type' => 'span', 294 | 'mark' => 'strong', 295 | 'content' => [ 296 | 'is very strong and emphasis' 297 | ] 298 | ], 299 | ' and just emphasis' 300 | ] 301 | ] 302 | ] 303 | ], 304 | '.' 305 | ] 306 | ]; 307 | $actual = BlockContent::ToTree($input); 308 | $this->assertEquals($expected, $actual); 309 | } 310 | 311 | public function testHandlesNumberedList() 312 | { 313 | $input = $this->loadFixture('list-numbered-blocks.json'); 314 | $expected = [[ 315 | 'type' => 'list', 316 | 'itemStyle' => 'number', 317 | 'items' => [ 318 | [ 319 | 'type' => 'block', 320 | 'style' => 'normal', 321 | 'content' => [ 322 | 'One' 323 | ] 324 | ], 325 | [ 326 | 'type' => 'block', 327 | 'style' => 'normal', 328 | 'content' => [ 329 | 'Two has ', 330 | [ 331 | 'type' => 'span', 332 | 'mark' => 'strong', 333 | 'content' => [ 334 | 'bold' 335 | ] 336 | ], 337 | ' word' 338 | ] 339 | ], 340 | [ 341 | 'type' => 'block', 342 | 'style' => 'h2', 343 | 'content' => [ 344 | 'Three' 345 | ] 346 | ] 347 | ] 348 | ]]; 349 | $actual = BlockContent::ToTree($input); 350 | $this->assertEquals($expected, $actual); 351 | } 352 | 353 | 354 | public function testHandlesBulletedList() 355 | { 356 | $input = $this->loadFixture('list-bulleted-blocks.json'); 357 | $expected = [[ 358 | 'type' => 'list', 359 | 'itemStyle' => 'bullet', 360 | 'items' => [ 361 | [ 362 | 'type' => 'block', 363 | 'style' => 'normal', 364 | 'content' => [ 365 | 'I am the most' 366 | ] 367 | ], 368 | [ 369 | 'type' => 'block', 370 | 'style' => 'normal', 371 | 'content' => [ 372 | 'expressive', 373 | [ 374 | 'type' => 'span', 375 | 'mark' => 'strong', 376 | 'content' => [ 377 | 'programmer' 378 | ] 379 | ], 380 | 'you know.' 381 | ] 382 | ], 383 | [ 384 | 'type' => 'block', 385 | 'style' => 'normal', 386 | 'content' => [ 387 | 'SAD!' 388 | ] 389 | ] 390 | ] 391 | ]]; 392 | $actual = BlockContent::ToTree($input); 393 | $this->assertEquals($expected, $actual); 394 | } 395 | 396 | public function testHandlesMultipleLists() 397 | { 398 | $input = $this->loadFixture('list-both-types-blocks.json'); 399 | $expected = [ 400 | [ 401 | 'type' => 'list', 402 | 'itemStyle' => 'bullet', 403 | 'items' => [ 404 | [ 405 | 'type' => 'block', 406 | 'style' => 'normal', 407 | 'content' => [ 408 | 'A single bulleted item' 409 | ] 410 | ] 411 | ] 412 | ], 413 | [ 414 | 'type' => 'list', 415 | 'itemStyle' => 'number', 416 | 'items' => [ 417 | [ 418 | 'type' => 'block', 419 | 'style' => 'normal', 420 | 'content' => [ 421 | 'First numbered' 422 | ] 423 | ], 424 | [ 425 | 'type' => 'block', 426 | 'style' => 'normal', 427 | 'content' => [ 428 | 'Second numbered' 429 | ] 430 | ] 431 | ] 432 | ], 433 | [ 434 | 'type' => 'list', 435 | 'itemStyle' => 'bullet', 436 | 'items' => [ 437 | [ 438 | 'type' => 'block', 439 | 'style' => 'normal', 440 | 'content' => [ 441 | 'A bullet with', 442 | [ 443 | 'type' => 'span', 444 | 'mark' => 'strong', 445 | 'content' => [ 446 | 'something strong' 447 | ] 448 | ] 449 | ] 450 | ] 451 | ] 452 | ] 453 | ]; 454 | $actual = BlockContent::ToTree($input); 455 | $this->assertEquals($expected, $actual); 456 | } 457 | 458 | public function testHandlesPlainH2Block() 459 | { 460 | $input = $this->loadFixture('h2-text.json'); 461 | $expected = [ 462 | 'type' => 'block', 463 | 'style' => 'h2', 464 | 'content' => [ 465 | 'Such h2 header, much amaze' 466 | ] 467 | ]; 468 | $actual = BlockContent::ToTree($input); 469 | $this->assertEquals($expected, $actual); 470 | } 471 | 472 | 473 | public function testHandlesNonBlockType() 474 | { 475 | $input = $this->loadFixture('non-block.json'); 476 | $expected = [ 477 | 'type' => 'author', 478 | 'attributes' => [ 479 | 'name' => 'Test Person' 480 | ] 481 | ]; 482 | $actual = BlockContent::ToTree($input); 483 | $this->assertEquals($expected, $actual); 484 | } 485 | 486 | public function testCanBeCalledAsInvokable() 487 | { 488 | $input = $this->loadFixture('non-block.json'); 489 | $expected = [ 490 | 'type' => 'author', 491 | 'attributes' => [ 492 | 'name' => 'Test Person' 493 | ] 494 | ]; 495 | 496 | $treeBuilder = new BlockContent\TreeBuilder(); 497 | $actual = $treeBuilder($input); 498 | $this->assertEquals($expected, $actual); 499 | } 500 | } 501 | -------------------------------------------------------------------------------- /test/ClientTest.php: -------------------------------------------------------------------------------- 1 | client = null; 29 | $this->errors = []; 30 | set_error_handler(array($this, 'errorHandler')); 31 | } 32 | 33 | public function errorHandler($errno, $errstr, $errfile, $errline, $errcontext) 34 | { 35 | $this->errors[] = compact('errno', 'errstr', 'errfile', 'errline', 'errcontext'); 36 | } 37 | 38 | public function testCanConstructNewClient() 39 | { 40 | $this->client = new Client([ 41 | 'projectId' => 'abc', 42 | 'dataset' => 'production', 43 | 'apiVersion' => '2019-01-01', 44 | ]); 45 | $this->assertInstanceOf(Client::class, $this->client); 46 | } 47 | 48 | public function testWarnsOnNoApiVersionSpecified() 49 | { 50 | $this->client = new Client([ 51 | 'projectId' => 'abc', 52 | 'dataset' => 'production', 53 | ]); 54 | $this->assertInstanceOf(Client::class, $this->client); 55 | $this->assertErrorTriggered(Client::NO_API_VERSION_WARNING, E_USER_DEPRECATED); 56 | } 57 | 58 | public function testWarnsOnServerWarnings() 59 | { 60 | $this->client = new Client([ 61 | 'projectId' => 'abc', 62 | 'dataset' => 'production', 63 | 'apiVersion' => '1', 64 | ]); 65 | 66 | $this->assertInstanceOf(Client::class, $this->client); 67 | $this->mockResponses([$this->mockJsonResponseBody(['result' => []], 200, ['X-Sanity-Warning' => 'Some error'])]); 68 | $this->client->request(['url' => '/projects']); 69 | $this->assertErrorTriggered('Some error', E_USER_WARNING); 70 | } 71 | 72 | /** 73 | * @expectedException Sanity\Exception\ConfigException 74 | * @expectedExceptionMessage Invalid ISO-date 75 | */ 76 | public function testThrowsOnInvalidDate() 77 | { 78 | $this->client = new Client([ 79 | 'projectId' => 'abc', 80 | 'dataset' => 'production', 81 | 'apiVersion' => '2018-14-03', 82 | ]); 83 | } 84 | 85 | /** 86 | * @expectedException Sanity\Exception\ConfigException 87 | * @expectedExceptionMessage Invalid API version 88 | */ 89 | public function testThrowsOnInvalidApiVersion() 90 | { 91 | $this->client = new Client([ 92 | 'projectId' => 'abc', 93 | 'dataset' => 'production', 94 | 'apiVersion' => '3', 95 | ]); 96 | } 97 | 98 | public function testDoesNotThrowOnExperimentalApiVersion() 99 | { 100 | $this->client = new Client([ 101 | 'projectId' => 'abc', 102 | 'dataset' => 'production', 103 | 'apiVersion' => 'X', 104 | ]); 105 | } 106 | 107 | public function testDoesNotThrowOnApiVersionOne() 108 | { 109 | $this->client = new Client([ 110 | 'projectId' => 'abc', 111 | 'dataset' => 'production', 112 | 'apiVersion' => '1', 113 | ]); 114 | } 115 | 116 | /** 117 | * @expectedException Sanity\Exception\ConfigException 118 | * @expectedExceptionMessage Configuration must contain `projectId` 119 | */ 120 | public function testThrowsWhenConstructingClientWithoutProjectId() 121 | { 122 | $this->client = new Client([ 123 | 'dataset' => 'production', 124 | 'apiVersion' => '2019-01-01', 125 | ]); 126 | } 127 | 128 | /** 129 | * @expectedException Sanity\Exception\ConfigException 130 | * @expectedExceptionMessage Configuration must contain `dataset` 131 | */ 132 | public function testThrowsWhenConstructingClientWithoutDataset() 133 | { 134 | $this->client = new Client(['projectId' => 'abc', 'apiVersion' => '2019-01-01']); 135 | } 136 | 137 | /** 138 | * @expectedException Sanity\Exception\ConfigException 139 | * @expectedExceptionMessage Configuration `perspective` parameter must be a string 140 | */ 141 | public function testThrowsWhenConstructingClientWithNonStringPerspective() 142 | { 143 | $this->client = new Client([ 144 | 'projectId' => 'abc', 145 | 'dataset' => 'production', 146 | 'apiVersion' => '1', 147 | 'perspective' => 1337, 148 | ]); 149 | } 150 | 151 | public function testCanSetAndGetConfig() 152 | { 153 | $this->client = new Client([ 154 | 'projectId' => 'abc', 155 | 'dataset' => 'production', 156 | 'apiVersion' => '2019-01-01', 157 | ]); 158 | $this->assertEquals('production', $this->client->config()['dataset']); 159 | $this->assertEquals($this->client, $this->client->config(['dataset' => 'staging'])); 160 | $this->assertEquals('staging', $this->client->config()['dataset']); 161 | } 162 | 163 | public function testCanCreateProjectlessClient() 164 | { 165 | $mockBody = ['some' => 'response']; 166 | 167 | $this->history = []; 168 | $historyMiddleware = Middleware::history($this->history); 169 | 170 | $stack = HandlerStack::create(new MockHandler([$this->mockJsonResponseBody($mockBody)])); 171 | $stack->push($historyMiddleware); 172 | 173 | $this->client = new Client([ 174 | 'useProjectHostname' => false, 175 | 'handler' => $stack, 176 | 'token' => 'mytoken', 177 | 'apiVersion' => '2019-01-22', 178 | ]); 179 | 180 | $response = $this->client->request(['url' => '/projects']); 181 | $this->assertEquals($mockBody, $response); 182 | } 183 | 184 | public function testCanGetDocument() 185 | { 186 | $expected = ['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire']; 187 | $mockBody = ['documents' => [$expected]]; 188 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['apiVersion' => '2019-01-20']); 189 | 190 | $this->assertEquals($expected, $this->client->getDocument('someDocId')); 191 | $this->assertPreviousRequest(['url' => 'https://abc.api.sanity.io/v2019-01-20/data/doc/production/someDocId']); 192 | $this->assertPreviousRequest(['headers' => ['Authorization' => 'Bearer muchsecure']]); 193 | } 194 | 195 | public function testCanGetDocumentFromCdn() 196 | { 197 | $expected = ['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire']; 198 | $mockBody = ['documents' => [$expected]]; 199 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['useCdn' => true, 'token' => null]); 200 | 201 | $this->assertEquals($expected, $this->client->getDocument('someDocId')); 202 | $this->assertPreviousRequest(['url' => 'https://abc.apicdn.sanity.io/v2019-01-01/data/doc/production/someDocId']); 203 | } 204 | 205 | public function testIncludesUserAgent() 206 | { 207 | $expected = ['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire']; 208 | $mockBody = ['documents' => [$expected]]; 209 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 210 | 211 | $this->assertEquals($expected, $this->client->getDocument('someDocId')); 212 | $this->assertPreviousRequest(['url' => 'https://abc.api.sanity.io/v2019-01-01/data/doc/production/someDocId']); 213 | $this->assertPreviousRequest(['headers' => ['User-Agent' => 'sanity-php ' . Version::VERSION]]); 214 | } 215 | 216 | /** 217 | * @expectedException Sanity\Exception\ServerException 218 | * @expectedExceptionMessage SomeError - Server returned some error 219 | */ 220 | public function testThrowsServerExceptionOn5xxErrors() 221 | { 222 | $mockBody = ['error' => 'SomeError', 'message' => 'Server returned some error']; 223 | $this->mockResponses([$this->mockJsonResponseBody($mockBody, 500)]); 224 | $this->client->getDocument('someDocId'); 225 | } 226 | 227 | public function testCanQueryWithTokenAndCdnOption() 228 | { 229 | $query = '*[seats >= 2]'; 230 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 231 | $mockBody = ['result' => $expected]; 232 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], [ 233 | 'useCdn' => true, 234 | 'token' => 'nice', 235 | ]); 236 | 237 | $this->assertEquals($expected, $this->client->fetch($query)); 238 | $this->assertPreviousRequest([ 239 | 'url' => 'https://abc.apicdn.sanity.io/v2019-01-01/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D', 240 | 'headers' => ['Authorization' => 'Bearer nice'], 241 | ]); 242 | } 243 | 244 | public function testCanQueryForDocumentsWithoutParams() 245 | { 246 | $query = '*[seats >= 2]'; 247 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 248 | $mockBody = ['result' => $expected]; 249 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 250 | 251 | $this->assertEquals($expected, $this->client->fetch($query)); 252 | $this->assertPreviousRequest([ 253 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D', 254 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 255 | ]); 256 | } 257 | 258 | public function testCanQueryForDocumentsWithParams() 259 | { 260 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 261 | $mockBody = ['result' => $expected]; 262 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 263 | 264 | $query = '*[seats >= $minSeats]'; 265 | $params = ['minSeats' => 2]; 266 | 267 | $expectedUrl = 'https://abc.api.sanity.io/v2019-01-01/data/query/production?'; 268 | $expectedUrl .= 'query=%2A%5Bseats%20%3E%3D%20%24minSeats%5D&%24minSeats=2'; 269 | 270 | $this->assertEquals($expected, $this->client->fetch($query, $params)); 271 | $this->assertPreviousRequest([ 272 | 'url' => $expectedUrl, 273 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 274 | ]); 275 | } 276 | 277 | public function testCanQueryWithPerspective() 278 | { 279 | $query = '*[seats >= 2]'; 280 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 281 | $mockBody = ['result' => $expected]; 282 | $this->mockResponses( 283 | [$this->mockJsonResponseBody($mockBody)], 284 | ['perspective' => 'previewDrafts', 'apiVersion' => '2023-06-03'] 285 | ); 286 | 287 | $this->assertEquals($expected, $this->client->fetch($query)); 288 | $this->assertPreviousRequest([ 289 | 'url' => 'https://abc.api.sanity.io/v2023-06-03/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D&perspective=previewDrafts', 290 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 291 | ]); 292 | } 293 | 294 | /** 295 | * @expectedException Sanity\Exception\ClientException 296 | * @expectedExceptionMessage previewDrafts perspective is not allowed for CDN requests 297 | */ 298 | public function testThrowsWhenPerspectiveDoesNotSupportCdn() 299 | { 300 | $query = '*[seats >= 2]'; 301 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 302 | $mockBody = ['error' => [ 303 | 'description' => 'previewDrafts perspective is not allowed for CDN requests', 304 | 'type' => 'httpInvalidQueryParamsError' 305 | ]]; 306 | $this->mockResponses( 307 | [$this->mockJsonResponseBody($mockBody, 400)], 308 | ['perspective' => 'previewDrafts', 'apiVersion' => '2023-06-03', 'useCdn' => true] 309 | ); 310 | 311 | $this->assertEquals($expected, $this->client->fetch($query)); 312 | $this->assertPreviousRequest([ 313 | 'url' => 'https://abc.apicdn.sanity.io/v2023-06-03/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D&perspective=previewDrafts', 314 | ]); 315 | } 316 | 317 | public function testCanQueryForDocumentsThroughAlias() 318 | { 319 | $query = '*[seats >= 2]'; 320 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 321 | $mockBody = ['result' => $expected]; 322 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['dataset' => '~current']); 323 | 324 | $this->assertEquals($expected, $this->client->fetch($query)); 325 | $this->assertPreviousRequest([ 326 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/query/~current?query=%2A%5Bseats%20%3E%3D%202%5D', 327 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 328 | ]); 329 | } 330 | 331 | public function testCanQueryForDocumentsWithoutFilteringResponse() 332 | { 333 | $query = '*[seats >= 2]'; 334 | $results = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 335 | $mockBody = ['result' => $results]; 336 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 337 | 338 | $this->assertEquals($mockBody, $this->client->fetch($query, null, ['filterResponse' => false])); 339 | $this->assertPreviousRequest([ 340 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D', 341 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 342 | ]); 343 | } 344 | 345 | public function testCanQueryForDocumentsFromCdn() 346 | { 347 | $query = '*[seats >= 2]'; 348 | $expected = [['_id' => 'someDocId', '_type' => 'bike', 'name' => 'Tandem Extraordinaire', 'seats' => 2]]; 349 | $mockBody = ['result' => $expected]; 350 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['useCdn' => true, 'token' => null]); 351 | 352 | $this->assertEquals($expected, $this->client->fetch($query)); 353 | $this->assertPreviousRequest([ 354 | 'url' => 'https://abc.apicdn.sanity.io/v2019-01-01/data/query/production?query=%2A%5Bseats%20%3E%3D%202%5D' 355 | ]); 356 | } 357 | 358 | /** 359 | * @expectedException Sanity\Exception\ClientException 360 | * @expectedExceptionMessage Param $minSeats referenced, but not provided 361 | */ 362 | public function testThrowsClientExceptionOn4xxErrors() 363 | { 364 | $mockBody = ['error' => [ 365 | 'description' => 'Param $minSeats referenced, but not provided', 366 | 'type' => 'queryParseError' 367 | ]]; 368 | $this->mockResponses([$this->mockJsonResponseBody($mockBody, 400)]); 369 | $this->client->fetch('*[seats >= $minSeats]'); 370 | } 371 | 372 | public function testCanCreateDocument() 373 | { 374 | $document = ['_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 375 | $result = ['_id' => 'someNewDocId'] + $document; 376 | $mockBody = ['results' => [['id' => 'someNewDocId', 'document' => $result]]]; 377 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 378 | 379 | $this->assertEquals($result, $this->client->create($document)); 380 | $this->assertPreviousRequest([ 381 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 382 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 383 | 'requestBody' => json_encode(['mutations' => [['create' => $document]]]) 384 | ]); 385 | } 386 | 387 | public function testDoesNotUseCdnForMutations() 388 | { 389 | $document = ['_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 390 | $result = ['_id' => 'someNewDocId'] + $document; 391 | $mockBody = ['results' => [['id' => 'someNewDocId', 'document' => $result]]]; 392 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['useCdn' => true, 'token' => null]); 393 | 394 | $this->assertEquals($result, $this->client->create($document)); 395 | $this->assertPreviousRequest([ 396 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 397 | 'requestBody' => json_encode(['mutations' => [['create' => $document]]]) 398 | ]); 399 | } 400 | 401 | public function testDoesNotUseCdnForMutationsWithToken() 402 | { 403 | $document = ['_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 404 | $result = ['_id' => 'someNewDocId'] + $document; 405 | $mockBody = ['results' => [['id' => 'someNewDocId', 'document' => $result]]]; 406 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)], ['useCdn' => true, 'token' => 'abc123']); 407 | 408 | $this->assertEquals($result, $this->client->create($document)); 409 | $this->assertPreviousRequest([ 410 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 411 | 'requestBody' => json_encode(['mutations' => [['create' => $document]]]) 412 | ]); 413 | } 414 | 415 | /** 416 | * @expectedException Sanity\Exception\InvalidArgumentException 417 | * @expectedExceptionMessage _type 418 | */ 419 | public function testThrowsWhenCreatingDocumentWithoutType() 420 | { 421 | $this->mockResponses([]); 422 | $this->client->create(['foo' => 'bar']); 423 | } 424 | 425 | public function testCanRunMutationsAndReturnFirstIdOnly() 426 | { 427 | $document = ['_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 428 | $mutations = [['create' => $document]]; 429 | $result = ['_id' => 'someNewDocId'] + $document; 430 | $mockBody = [ 431 | 'transactionId' => 'foo', 432 | 'results' => [['id' => 'someNewDocId', 'document' => $result]], 433 | 'documentId' => 'someNewDocId', 434 | ]; 435 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 436 | 437 | $this->assertEquals($mockBody, $this->client->mutate($mutations, [ 438 | 'returnFirst' => true 439 | ])); 440 | 441 | $this->assertPreviousRequest([ 442 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 443 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 444 | 'requestBody' => json_encode(['mutations' => $mutations]) 445 | ]); 446 | } 447 | 448 | public function testMutateWillSerializePatchInstance() 449 | { 450 | $document = ['_id' => 'someDocId', '_type' => 'someType', 'count' => 2]; 451 | $mockBody = ['transactionId' => 'poc', 'results' => [['id' => 'someDocId', 'document' => $document]]]; 452 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 453 | 454 | $patch = $this->client->patch('someDocId')->inc(['count' => 1]); 455 | $this->client->mutate($patch); 456 | 457 | $this->assertPreviousRequest([ 458 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 459 | 'requestBody' => json_encode(['mutations' => [['patch' => $patch->serialize()]]]) 460 | ]); 461 | } 462 | 463 | public function testMutateWillSerializeTransactionInstance() 464 | { 465 | $document = ['_id' => 'someDocId', '_type' => 'someType', 'count' => 2]; 466 | $mockBody = ['transactionId' => 'poc', 'results' => [['id' => 'someDocId', 'document' => $document]]]; 467 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 468 | 469 | $transaction = $this->client->transaction()->patch('someDocId', ['count' => 1]); 470 | $this->client->mutate($transaction); 471 | 472 | $this->assertPreviousRequest([ 473 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 474 | 'requestBody' => json_encode(['mutations' => $transaction->serialize()]) 475 | ]); 476 | } 477 | 478 | public function testCanCreateDocumentWithVisibilityOption() 479 | { 480 | $document = ['_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 481 | $result = ['_id' => 'someNewDocId'] + $document; 482 | $mockBody = ['results' => [['id' => 'someNewDocId', 'document' => $result]]]; 483 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 484 | 485 | $this->assertEquals($result, $this->client->create($document, ['visibility' => 'async'])); 486 | $this->assertPreviousRequest([ 487 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true&visibility=async', 488 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 489 | 'requestBody' => json_encode(['mutations' => [['create' => $document]]]) 490 | ]); 491 | } 492 | 493 | public function testCanCreateDocumentIfNotExists() 494 | { 495 | $document = ['_id' => 'foobar', '_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 496 | $mockBody = ['results' => [['id' => 'foobar', 'document' => $document]]]; 497 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 498 | 499 | $this->assertEquals($document, $this->client->createIfNotExists($document)); 500 | $this->assertPreviousRequest([ 501 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 502 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 503 | 'requestBody' => json_encode(['mutations' => [['createIfNotExists' => $document]]]) 504 | ]); 505 | } 506 | 507 | /** 508 | * @expectedException Sanity\Exception\InvalidArgumentException 509 | * @expectedExceptionMessage _id 510 | */ 511 | public function testThrowsWhenCallingCreateIfNotExistsWithoutId() 512 | { 513 | $this->mockResponses([]); 514 | $this->client->createIfNotExists(['_type' => 'bike']); 515 | } 516 | 517 | public function testCanCreateOrReplaceDocument() 518 | { 519 | $document = ['_id' => 'foobar', '_type' => 'bike', 'seats' => 12, 'name' => 'Dusinsykkel']; 520 | $mockBody = ['results' => [['id' => 'foobar', 'document' => $document]]]; 521 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 522 | 523 | $this->assertEquals($document, $this->client->createOrReplace($document)); 524 | $this->assertPreviousRequest([ 525 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 526 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 527 | 'requestBody' => json_encode(['mutations' => [['createOrReplace' => $document]]]) 528 | ]); 529 | } 530 | 531 | /** 532 | * @expectedException Sanity\Exception\InvalidArgumentException 533 | * @expectedExceptionMessage _id 534 | */ 535 | public function testThrowsWhenCallingCreateOrReplaceWithoutId() 536 | { 537 | $this->mockResponses([]); 538 | $this->client->createOrReplace(['_type' => 'bike']); 539 | } 540 | 541 | public function testCanGeneratePatch() 542 | { 543 | $this->client = new Client([ 544 | 'projectId' => 'abc', 545 | 'dataset' => 'production', 546 | 'apiVersion' => '2019-01-01', 547 | ]); 548 | $this->assertInstanceOf(Patch::class, $this->client->patch('someDocId')); 549 | } 550 | 551 | public function testCanGeneratePatchWithInitialOperations() 552 | { 553 | $this->client = new Client([ 554 | 'projectId' => 'abc', 555 | 'dataset' => 'production', 556 | 'apiVersion' => '2019-01-01', 557 | ]); 558 | $serialized = $this->client->patch('someDocId', ['inc' => ['seats' => 1]])->serialize(); 559 | $this->assertEquals(['id' => 'someDocId', 'inc' => ['seats' => 1]], $serialized); 560 | } 561 | 562 | public function testCanCommitPatch() 563 | { 564 | $document = ['_id' => 'someDocId', '_type' => 'bike', 'seats' => 2]; 565 | $mockBody = ['results' => [['id' => 'someDocId', 'document' => $document]]]; 566 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 567 | $newDoc = $this->client 568 | ->patch('someDocId', ['inc' => ['seats' => 1]]) 569 | ->setIfMissing(['seats' => 1]) 570 | ->commit(); 571 | 572 | $this->assertEquals($document, $newDoc); 573 | $this->assertPreviousRequest([ 574 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 575 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 576 | 'requestBody' => json_encode(['mutations' => [['patch' => [ 577 | 'id' => 'someDocId', 578 | 'inc' => ['seats' => 1], 579 | 'setIfMissing' => ['seats' => 1] 580 | ]]]]) 581 | ]); 582 | } 583 | 584 | public function testCanGenerateTransaction() 585 | { 586 | $this->client = new Client([ 587 | 'projectId' => 'abc', 588 | 'dataset' => 'production', 589 | 'apiVersion' => '2019-01-01', 590 | ]); 591 | $this->assertInstanceOf(Transaction::class, $this->client->transaction()); 592 | } 593 | 594 | public function testCanGenerateTransactionWithInitialOperations() 595 | { 596 | $this->client = new Client([ 597 | 'projectId' => 'abc', 598 | 'dataset' => 'production', 599 | 'apiVersion' => '2019-01-01', 600 | ]); 601 | $serialized = $this->client->transaction([['create' => ['_type' => 'bike']]])->serialize(); 602 | $this->assertEquals([['create' => ['_type' => 'bike']]], $serialized); 603 | } 604 | 605 | public function testCanCommitTransaction() 606 | { 607 | $mockBody = ['transactionId' => 'moo', 'results' => [['id' => 'someNewDocId', 'operation' => 'create']]]; 608 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 609 | $result = $this->client 610 | ->transaction([['create' => ['_type' => 'bike']]]) 611 | ->commit(); 612 | 613 | $expected = $mockBody + ['documentIds' => ['someNewDocId']]; 614 | $this->assertEquals($expected, $result); 615 | $this->assertPreviousRequest([ 616 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true', 617 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 618 | 'requestBody' => json_encode(['mutations' => [['create' => [ 619 | '_type' => 'bike' 620 | ]]]]) 621 | ]); 622 | } 623 | 624 | public function testCanHaveTransactionDocumentsReturned() 625 | { 626 | $results = [ 627 | ['id' => '123', 'document' => ['_id' => '123', '_type' => 'bike', 'title' => 'Tandem']], 628 | ['id' => '456', 'document' => ['_id' => '456', '_type' => 'bike', 'title' => 'City Bike']] 629 | ]; 630 | $mockBody = ['transactionId' => 'moo', 'results' => $results]; 631 | $mutations = [ 632 | ['create' => ['_type' => 'bike', 'title' => 'Tandem']], 633 | ['create' => ['_type' => 'bike', 'title' => 'City Bike']] 634 | ]; 635 | 636 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 637 | $result = $this->client 638 | ->transaction($mutations) 639 | ->commit(['returnDocuments' => true]); 640 | 641 | $expected = ['123' => $results[0]['document'], '456' => $results[1]['document']]; 642 | $this->assertEquals($expected, $result); 643 | $this->assertPreviousRequest([ 644 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true&returnDocuments=true', 645 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 646 | 'requestBody' => json_encode(['mutations' => $mutations]) 647 | ]); 648 | } 649 | 650 | public function testCanDeleteDocument() 651 | { 652 | $mockBody = ['transactionId' => 'fnatt', 'results' => [['id' => 'foobar', 'operation' => 'delete']]]; 653 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 654 | 655 | $expected = $mockBody + ['documentIds' => ['foobar']]; 656 | $this->assertEquals($expected, $this->client->delete('foobar')); 657 | $this->assertPreviousRequest([ 658 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/data/mutate/production?returnIds=true', 659 | 'headers' => ['Authorization' => 'Bearer muchsecure'], 660 | 'requestBody' => json_encode(['mutations' => [['delete' => ['id' => 'foobar']]]]) 661 | ]); 662 | } 663 | 664 | /** 665 | * @expectedException Sanity\Exception\ServerException 666 | * @expectedExceptionMessage Some error message 667 | */ 668 | public function testResolvesErrorMessageFromNonStandardResponseWithOnlyError() 669 | { 670 | $mockBody = ['error' => 'Some error message']; 671 | $this->mockResponses([$this->mockJsonResponseBody($mockBody, 500)]); 672 | $this->client->getDocument('someDocId'); 673 | } 674 | 675 | /** 676 | * @expectedException Sanity\Exception\ServerException 677 | * @expectedExceptionMessage Some error message 678 | */ 679 | public function testResolvesErrorMessageFromNonStandardResponseWithOnlyMessage() 680 | { 681 | $mockBody = ['message' => 'Some error message']; 682 | $this->mockResponses([$this->mockJsonResponseBody($mockBody, 500)]); 683 | $this->client->getDocument('someDocId'); 684 | } 685 | 686 | /** 687 | * @expectedException Sanity\Exception\ServerException 688 | * @expectedExceptionMessage Unknown error; body: {"some":"thing"} 689 | */ 690 | public function testResolvesErrorMessageFromNonStandardResponse() 691 | { 692 | $mockBody = ['some' => 'thing']; 693 | $this->mockResponses([$this->mockJsonResponseBody($mockBody, 500)]); 694 | $this->client->getDocument('someDocId'); 695 | } 696 | 697 | public function testCanGetResponseFromRequestException() 698 | { 699 | $this->mockResponses([$this->mockJsonResponseBody(['some' => 'thing'], 500)]); 700 | try { 701 | $this->client->getDocument('someDocId'); 702 | } catch (ServerException $error) { 703 | $body = (string) $error->getResponse()->getBody(); 704 | $this->assertEquals(json_encode(['some' => 'thing']), $body); 705 | $this->assertEquals(json_encode(['some' => 'thing']), $error->getResponseBody()); 706 | $this->assertEquals(500, $error->getStatusCode()); 707 | } 708 | } 709 | 710 | /** 711 | * @expectedException Sanity\Exception\InvalidArgumentException 712 | * @expectedExceptionMessage Invalid selection 713 | */ 714 | public function testThrowsOnInvalidSelections() 715 | { 716 | new Selection(['foo' => 'bar']); 717 | } 718 | 719 | public function testCanSerializeQuerySelection() 720 | { 721 | $sel = new Selection(['query' => '*']); 722 | $this->assertEquals(['query' => '*'], $sel->serialize()); 723 | } 724 | 725 | public function testCanSerializeMultiIdSelection() 726 | { 727 | $sel = new Selection(['abc', '123']); 728 | $this->assertEquals(['id' => ['abc', '123']], $sel->serialize()); 729 | } 730 | 731 | public function testCanSerializeSingleIdSelection() 732 | { 733 | $sel = new Selection('abc123'); 734 | $this->assertEquals(['id' => 'abc123'], $sel->serialize()); 735 | } 736 | 737 | public function testCanJsonEncodeSelection() 738 | { 739 | $sel = new Selection('abc123'); 740 | $this->assertEquals(json_encode(['id' => 'abc123']), json_encode($sel)); 741 | } 742 | 743 | /** 744 | * Asset tests 745 | */ 746 | public function testUploadAssetFromStringDefaultMime() 747 | { 748 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 749 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 750 | $mockBody = ['document' => $document]; 751 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 752 | $asset = $this->client->uploadAssetFromString('image', $buffer); 753 | 754 | $this->assertEquals($document['_id'], $asset['_id']); 755 | $this->assertPreviousRequest([ 756 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production', 757 | 'headers' => ['Content-Type' => 'application/octet-stream', 'Content-Length' => 1876], 758 | 'requestBody' => $buffer 759 | ]); 760 | } 761 | 762 | public function testUploadAssetFromStringSpecifyMime() 763 | { 764 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 765 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 766 | $mockBody = ['document' => $document]; 767 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 768 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['contentType' => 'application/octet-stream']); 769 | 770 | $this->assertEquals($document['_id'], $asset['_id']); 771 | $this->assertPreviousRequest([ 772 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production', 773 | 'headers' => ['Content-Type' => 'application/octet-stream', 'Content-Length' => 1876], 774 | 'requestBody' => $buffer 775 | ]); 776 | } 777 | 778 | public function testUploadAssetFromStringSpecifyFilename() 779 | { 780 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 781 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 782 | $mockBody = ['document' => $document]; 783 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 784 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['filename' => 'my-favicon.png']); 785 | 786 | $this->assertEquals($document['_id'], $asset['_id']); 787 | $this->assertPreviousRequest([ 788 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?filename=my-favicon.png', 789 | 'requestBody' => $buffer 790 | ]); 791 | } 792 | 793 | public function testUploadAssetFromStringSpecifyMetaExtration() 794 | { 795 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 796 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 797 | $mockBody = ['document' => $document]; 798 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 799 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['extract' => ['exif', 'location']]); 800 | 801 | $this->assertEquals($document['_id'], $asset['_id']); 802 | $this->assertPreviousRequest([ 803 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?meta%5B0%5D=exif&meta%5B1%5D=location', 804 | 'requestBody' => $buffer 805 | ]); 806 | } 807 | 808 | public function testUploadAssetFromStringSpecifyNoMetaExtraction() 809 | { 810 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 811 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 812 | $mockBody = ['document' => $document]; 813 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 814 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['extract' => []]); 815 | 816 | $this->assertEquals($document['_id'], $asset['_id']); 817 | $this->assertPreviousRequest([ 818 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?meta%5B0%5D=none', 819 | 'requestBody' => $buffer 820 | ]); 821 | } 822 | 823 | public function testUploadAssetFromStringSpecifyAllStringMeta() 824 | { 825 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 826 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 827 | $mockBody = ['document' => $document]; 828 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 829 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['filename' => 'my-favicon.png', 'label' => 'wat-label', 'title' => 'Sanity Favicon', 'description' => 'Favicon used for shortcuts and such', 'creditLine' => '(c) Sanity.io']); 830 | 831 | $this->assertEquals($document['_id'], $asset['_id']); 832 | $this->assertPreviousRequest([ 833 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?label=wat-label&title=Sanity%20Favicon&description=Favicon%20used%20for%20shortcuts%20and%20such&creditLine=%28c%29%20Sanity.io&filename=my-favicon.png', 834 | 'requestBody' => $buffer 835 | ]); 836 | } 837 | 838 | public function testUploadAssetFromStringSpecifySource() 839 | { 840 | $buffer = file_get_contents(__DIR__ . '/fixtures/favicon.png'); 841 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 842 | $mockBody = ['document' => $document]; 843 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 844 | $asset = $this->client->uploadAssetFromString('image', $buffer, ['filename' => 'my-favicon.png', 'source' => ['id' => 'abc123', 'url' => 'https://my.source/img.png', 'name' => 'The Web']]); 845 | 846 | $this->assertEquals($document['_id'], $asset['_id']); 847 | $this->assertPreviousRequest([ 848 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?filename=my-favicon.png&sourceId=abc123&sourceName=The%20Web&sourceUrl=https%3A%2F%2Fmy.source%2Fimg.png', 849 | 'requestBody' => $buffer 850 | ]); 851 | } 852 | 853 | public function testUploadAssetFromStringWithFile() 854 | { 855 | $buffer = file_get_contents(__DIR__ . '/fixtures/document.json'); 856 | $document = ['_id' => 'file-a89dac4c7845079cf854b7478101daf7a058bd82-json', '_type' => 'sanity.fileAsset', 'extension' => 'json']; 857 | $mockBody = ['document' => $document]; 858 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 859 | $asset = $this->client->uploadAssetFromString('file', $buffer); 860 | 861 | $this->assertEquals($document['_id'], $asset['_id']); 862 | $this->assertPreviousRequest([ 863 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/files/production', 864 | 'headers' => ['Content-Type' => 'application/octet-stream', 'Content-Length' => 1336], 865 | 'requestBody' => $buffer 866 | ]); 867 | } 868 | 869 | public function testUploadAssetFromStringWithJsonFile() 870 | { 871 | $buffer = file_get_contents(__DIR__ . '/fixtures/document.json'); 872 | $document = ['_id' => 'file-a89dac4c7845079cf854b7478101daf7a058bd82-json', '_type' => 'sanity.fileAsset', 'extension' => 'json']; 873 | $mockBody = ['document' => $document]; 874 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 875 | $asset = $this->client->uploadAssetFromString('file', $buffer); 876 | 877 | $this->assertEquals($document['_id'], $asset['_id']); 878 | $this->assertPreviousRequest([ 879 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/files/production', 880 | 'requestBody' => $buffer 881 | ]); 882 | } 883 | 884 | public function testUploadAssetFromFilePreservesFilename() 885 | { 886 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 887 | $mockBody = ['document' => $document]; 888 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 889 | $asset = $this->client->uploadAssetFromFile('image', __DIR__ . '/fixtures/favicon.png'); 890 | 891 | $this->assertEquals($document['_id'], $asset['_id']); 892 | $this->assertPreviousRequest([ 893 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production?filename=favicon.png', 894 | 'headers' => ['Content-Length' => 1876], 895 | 'requestBody' => $buffer 896 | ]); 897 | } 898 | 899 | public function testUploadAssetFromFileCanDropFilename() 900 | { 901 | $document = ['_id' => 'image-2638c439689de9ea323ecb8aed6831541fd85cdc-57x57-png', '_type' => 'sanity.imageAsset', 'extension' => 'png']; 902 | $mockBody = ['document' => $document]; 903 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 904 | $asset = $this->client->uploadAssetFromFile('image', __DIR__ . '/fixtures/favicon.png', ['preserveFilename' => false]); 905 | 906 | $this->assertEquals($document['_id'], $asset['_id']); 907 | $this->assertPreviousRequest([ 908 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/images/production', 909 | 'headers' => ['Content-Length' => 1876], 910 | 'requestBody' => $buffer 911 | ]); 912 | } 913 | 914 | public function testUploadAssetFromNonImageFile() 915 | { 916 | $buffer = file_get_contents(__DIR__ . '/fixtures/document.json'); 917 | $document = ['_id' => 'file-a89dac4c7845079cf854b7478101daf7a058bd82-json', '_type' => 'sanity.fileAsset', 'extension' => 'json']; 918 | $mockBody = ['document' => $document]; 919 | $this->mockResponses([$this->mockJsonResponseBody($mockBody)]); 920 | $asset = $this->client->uploadAssetFromFile('file', __DIR__ . '/fixtures/document.json'); 921 | 922 | $this->assertEquals($document['_id'], $asset['_id']); 923 | $this->assertPreviousRequest([ 924 | 'url' => 'https://abc.api.sanity.io/v2019-01-01/assets/files/production?filename=document.json', 925 | 'headers' => ['Content-Length' => 1336], 926 | 'requestBody' => $buffer 927 | ]); 928 | } 929 | 930 | /** 931 | * @expectedException Sanity\Exception\InvalidArgumentException 932 | * @expectedExceptionMessage Invalid asset type 933 | */ 934 | public function testUploadFromStringThrowsOnUnknownAssetType() 935 | { 936 | $this->mockResponses([]); 937 | $this->client->uploadAssetFromString('nope', 'yep'); 938 | } 939 | 940 | /** 941 | * @expectedException Sanity\Exception\InvalidArgumentException 942 | * @expectedExceptionMessage Invalid asset type 943 | */ 944 | public function testUploadFromFileThrowsOnUnknownAssetType() 945 | { 946 | $this->mockResponses([]); 947 | $this->client->uploadAssetFromFile('nope', 'yep'); 948 | } 949 | 950 | /** 951 | * @expectedException Sanity\Exception\InvalidArgumentException 952 | * @expectedExceptionMessage File does not exist 953 | * @expectedExceptionMessage nope.svg 954 | */ 955 | public function testUploadFromFileThrowsOnMissingFile() 956 | { 957 | $this->mockResponses([]); 958 | $this->client->uploadAssetFromFile('file', __DIR__ . '/fixtures/nope.svg'); 959 | } 960 | 961 | /** 962 | * @expectedException Sanity\Exception\InvalidArgumentException 963 | * @expectedExceptionMessage zero length 964 | */ 965 | public function testUploadFromFileThrowsOnEmptyFile() 966 | { 967 | $this->mockResponses([]); 968 | $this->client->uploadAssetFromFile('file', __DIR__ . '/fixtures/empty.txt'); 969 | } 970 | 971 | /** 972 | * @expectedException Sanity\Exception\InvalidArgumentException 973 | * @expectedExceptionMessage must be a string 974 | */ 975 | public function testUploadAssetThrowsOnInvalidStringMeta() 976 | { 977 | $this->mockResponses([]); 978 | $this->client->uploadAssetFromString('file', 'foobar', ['filename' => 123]); 979 | } 980 | 981 | /** 982 | * Helpers 983 | */ 984 | private function mockResponses($mocks, $clientOptions = []) 985 | { 986 | $this->history = []; 987 | $historyMiddleware = Middleware::history($this->history); 988 | 989 | $stack = HandlerStack::create(new MockHandler($mocks)); 990 | $stack->push($historyMiddleware); 991 | 992 | $this->initClient($stack, $clientOptions); 993 | } 994 | 995 | private function initClient($stack = null, $clientOptions = []) 996 | { 997 | $this->client = new Client(array_merge([ 998 | 'projectId' => 'abc', 999 | 'dataset' => 'production', 1000 | 'token' => 'muchsecure', 1001 | 'apiVersion' => '2019-01-01', 1002 | 'handler' => $stack, 1003 | ], $clientOptions)); 1004 | } 1005 | 1006 | private function mockJsonResponseBody($body, $statusCode = 200, $headers = []) 1007 | { 1008 | return new Response($statusCode, array_merge(['Content-Type' => 'application/json'], $headers), json_encode($body)); 1009 | } 1010 | 1011 | private function assertRequest($expected, $request) 1012 | { 1013 | if (isset($expected['url'])) { 1014 | $this->assertEquals($expected['url'], (string) $request['request']->getUri()); 1015 | } 1016 | 1017 | if (isset($expected['headers'])) { 1018 | foreach ($expected['headers'] as $header => $value) { 1019 | $this->assertEquals($value, $request['request']->getHeaderLine($header)); 1020 | } 1021 | } 1022 | 1023 | if (isset($expected['requestBody'])) { 1024 | $this->assertEquals($expected['requestBody'], (string) $request['request']->getBody()); 1025 | } 1026 | } 1027 | 1028 | private function assertPreviousRequest($expected) 1029 | { 1030 | $this->assertRequest($expected, $this->history[0]); 1031 | } 1032 | 1033 | private function assertErrorTriggered($errstr, $errno) 1034 | { 1035 | foreach ($this->errors as $error) { 1036 | if ($error['errstr'] === $errstr && $error['errno'] === $errno) { 1037 | return; 1038 | } 1039 | } 1040 | 1041 | $numErrors = count($this->errors); 1042 | $singleError = count($this->errors) > 0 ? $this->errors[0] : false; 1043 | $errorMessage = 'Error with level ' . $errno . ' and message "' . $errstr . '" not triggered. '; 1044 | if ($numErrors === 0) { 1045 | $errorMessage .= 'No errors triggered.'; 1046 | } else if ($numErrors === 1) { 1047 | $err = $this->errors[0]; 1048 | $errorMessage .= 'Error triggered: "' . $err['errstr'] . '" (level ' . $err['errno'] . ')'; 1049 | } else { 1050 | $errorMessage .= $numErrors . ' errors triggered that did not match expectation'; 1051 | } 1052 | 1053 | 1054 | $this->fail($errorMessage); 1055 | } 1056 | } 1057 | -------------------------------------------------------------------------------- /test/PatchTest.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Patch::class, new Patch('abc123')); 12 | } 13 | 14 | public function testCanConstructNewPatchWithInitializedSelection() 15 | { 16 | $selection = new Selection('abc123'); 17 | $this->assertInstanceOf(Patch::class, new Patch($selection)); 18 | } 19 | 20 | public function testCanConstructNewPatchWithInitialOperations() 21 | { 22 | $patch = new Patch('abc123', ['inc' => ['count' => 1]]); 23 | $this->assertEquals(['id' => 'abc123', 'inc' => ['count' => 1]], $patch->serialize()); 24 | } 25 | 26 | public function testCanCreateMergePatch() 27 | { 28 | $patch = new Patch('abc123'); 29 | $this->assertSame($patch, $patch->merge(['foo' => 'bar'])); 30 | $this->assertEquals(['id' => 'abc123', 'merge' => ['foo' => 'bar']], $patch->serialize()); 31 | } 32 | 33 | public function testMergesWhenMultipleMergeOperationsAdded() 34 | { 35 | $patch = new Patch('abc123'); 36 | $this->assertSame($patch, $patch->merge(['foo' => 'bar'])); 37 | $this->assertSame($patch, $patch->merge(['bar' => 'baz'])); 38 | $this->assertSame($patch, $patch->merge(['foo' => 'moo'])); 39 | $this->assertEquals( 40 | ['id' => 'abc123', 'merge' => ['foo' => 'moo', 'bar' => 'baz']], 41 | $patch->serialize() 42 | ); 43 | } 44 | 45 | public function testCanCreateSetPatch() 46 | { 47 | $patch = new Patch('abc123'); 48 | $this->assertSame($patch, $patch->set(['foo' => 'bar'])); 49 | $this->assertEquals(['id' => 'abc123', 'set' => ['foo' => 'bar']], $patch->serialize()); 50 | } 51 | 52 | public function testCanCreateDiffMatchPatch() 53 | { 54 | $diff = '@@ -21,4 +21,10 @@\n-jump\n+somersault\n'; 55 | $patch = new Patch('abc123'); 56 | $this->assertSame($patch, $patch->diffMatchPatch(['body' => $diff])); 57 | $this->assertEquals(['id' => 'abc123', 'diffMatchPatch' => ['body' => $diff]], $patch->serialize()); 58 | } 59 | 60 | public function testCanCreateRemovePatch() 61 | { 62 | $patch = new Patch('abc123'); 63 | $this->assertSame($patch, $patch->remove(['foo', 'bar'])); 64 | $this->assertEquals(['id' => 'abc123', 'unset' => ['foo', 'bar']], $patch->serialize()); 65 | } 66 | 67 | /** 68 | * @expectedException Sanity\Exception\InvalidArgumentException 69 | * @expectedExceptionMessage array of attributes 70 | */ 71 | public function testThrowsWhenCallingRemoveWithoutArray() 72 | { 73 | $patch = new Patch('abc123'); 74 | $patch->remove('foobar'); 75 | } 76 | 77 | public function testCanCreateReplacePatch() 78 | { 79 | $patch = new Patch('abc123'); 80 | $this->assertSame($patch, $patch->replace(['foo' => 'bar'])); 81 | $this->assertEquals(['id' => 'abc123', 'set' => ['$' => ['foo' => 'bar']]], $patch->serialize()); 82 | } 83 | 84 | public function testReplacePatchOverridesPreviousSetPatch() 85 | { 86 | $patch = new Patch('abc123'); 87 | $this->assertSame($patch, $patch->set(['bar' => 'baz'])); 88 | $this->assertSame($patch, $patch->replace(['foo' => 'bar'])); 89 | $this->assertEquals(['id' => 'abc123', 'set' => ['$' => ['foo' => 'bar']]], $patch->serialize()); 90 | } 91 | 92 | public function testCanCreateIncPatch() 93 | { 94 | $patch = new Patch('abc123'); 95 | $this->assertSame($patch, $patch->inc(['count' => 1])); 96 | $this->assertEquals(['id' => 'abc123', 'inc' => ['count' => 1]], $patch->serialize()); 97 | } 98 | 99 | public function testCanCreateDecPatch() 100 | { 101 | $patch = new Patch('abc123'); 102 | $this->assertSame($patch, $patch->dec(['count' => 1])); 103 | $this->assertEquals(['id' => 'abc123', 'dec' => ['count' => 1]], $patch->serialize()); 104 | } 105 | 106 | /** 107 | * @expectedException Sanity\Exception\ConfigException 108 | * @expectedExceptionMessage mutate() method 109 | */ 110 | public function testThrowsWhenCallingCommitWithoutClientContext() 111 | { 112 | $patch = new Patch('abc123'); 113 | $patch->remove(['foo']); 114 | $patch->commit(); 115 | } 116 | 117 | public function testCanResetPatch() 118 | { 119 | $patch = new Patch('abc123'); 120 | $this->assertSame($patch, $patch->dec(['count' => 1])->inc(['count' => 2])); 121 | $this->assertEquals( 122 | ['id' => 'abc123', 'dec' => ['count' => 1], 'inc' => ['count' => 2]], 123 | $patch->serialize() 124 | ); 125 | 126 | $this->assertSame($patch, $patch->reset()); 127 | $this->assertEquals(['id' => 'abc123'], $patch->serialize()); 128 | } 129 | 130 | public function testCanJsonEncodePatch() 131 | { 132 | $patch = new Patch('abc123'); 133 | $this->assertSame($patch, $patch->dec(['count' => 1])); 134 | $this->assertEquals( 135 | json_encode(['id' => 'abc123', 'dec' => ['count' => 1]]), 136 | json_encode($patch) 137 | ); 138 | } 139 | 140 | public function testCanCreateAppendPatch() 141 | { 142 | $patch = new Patch('abc123'); 143 | $this->assertSame($patch, $patch->append('tags', ['foo', 'bar'])); 144 | $this->assertEquals([ 145 | 'id' => 'abc123', 146 | 'insert' => [ 147 | 'after' => 'tags[-1]', 148 | 'items' => ['foo', 'bar'] 149 | ] 150 | ], $patch->serialize()); 151 | } 152 | 153 | public function testCanCreatePrependPatch() 154 | { 155 | $patch = new Patch('abc123'); 156 | $this->assertSame($patch, $patch->prepend('tags', ['foo', 'bar'])); 157 | $this->assertEquals([ 158 | 'id' => 'abc123', 159 | 'insert' => [ 160 | 'before' => 'tags[0]', 161 | 'items' => ['foo', 'bar'] 162 | ] 163 | ], $patch->serialize()); 164 | } 165 | 166 | /** 167 | * @expectedException Sanity\Exception\InvalidArgumentException 168 | * @expectedExceptionMessage "at"-argument which is one of 169 | */ 170 | public function testThrowsWhenCallingInsertWithInvalidAtArgument() 171 | { 172 | $patch = new Patch('abc123'); 173 | $patch->insert('foo', 'tags[-1]', ['foo', 'bar']); 174 | } 175 | 176 | /** 177 | * @expectedException Sanity\Exception\InvalidArgumentException 178 | * @expectedExceptionMessage "selector"-argument which must be a string 179 | */ 180 | public function testThrowsWhenCallingInsertWithInvalidSelectorArgument() 181 | { 182 | $patch = new Patch('abc123'); 183 | $patch->insert('before', ['tags' => -1], ['foo', 'bar']); 184 | } 185 | 186 | /** 187 | * @expectedException Sanity\Exception\InvalidArgumentException 188 | * @expectedExceptionMessage "items"-argument which must be an array 189 | */ 190 | public function testThrowsWhenCallingInsertWithInvalidItemsArgument() 191 | { 192 | $patch = new Patch('abc123'); 193 | $patch->insert('before', 'tags[-1]', 'boing'); 194 | } 195 | 196 | public function testCanCreateSplicePatches() 197 | { 198 | $patch = function () { return new Patch('abc123'); }; 199 | $replaceFirst = $patch()->splice('tags', 0, 1, ['foo'])->serialize(); 200 | $insertInMiddle = $patch()->splice('tags', 5, 0, ['foo'])->serialize(); 201 | $deleteLast = $patch()->splice('tags', -1, 1)->serialize(); 202 | $deleteAllFromIndex = $patch()->splice('tags', 3, -1)->serialize(); 203 | $allFromIndexDefault = $patch()->splice('tags', 3)->serialize(); 204 | $negativeDelete = $patch()->splice('tags', -2, -2, ['foo'])->serialize(); 205 | 206 | $this->assertEquals($replaceFirst['insert'], ['replace' => 'tags[0:1]', 'items' => ['foo']]); 207 | $this->assertEquals($insertInMiddle['insert'], ['replace' => 'tags[5:5]', 'items' => ['foo']]); 208 | $this->assertEquals($deleteLast['insert'], ['replace' => 'tags[-2:]', 'items' => []]); 209 | $this->assertEquals($deleteAllFromIndex['insert'], ['replace' => 'tags[3:-1]', 'items' => []]); 210 | $this->assertEquals($allFromIndexDefault['insert'], ['replace' => 'tags[3:-1]', 'items' => []]); 211 | $this->assertEquals($negativeDelete, $patch()->splice('tags', -2, 0, ['foo'])->serialize()); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /test/Serializers/MyCustomImageSerializer.php: -------------------------------------------------------------------------------- 1 | getImageUrl($item, $htmlBuilder); 11 | $html = '
    '; 12 | $html .= ''; 13 | $html .= $caption ? '
    ' . $htmlBuilder->escape($caption) . '
    ' : ''; 14 | $html .= '
    '; 15 | return $html; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /test/TestCase.php: -------------------------------------------------------------------------------- 1 | assertInstanceOf(Transaction::class, new Transaction()); 13 | } 14 | 15 | public function testCanConstructNewTransactionWithInitializedSelection() 16 | { 17 | $selection = new Selection('abc123'); 18 | $this->assertInstanceOf(Transaction::class, new Transaction($selection)); 19 | } 20 | 21 | public function testCanConstructNewTransactionWithInitialOperations() 22 | { 23 | $transaction = new Transaction(['create' => ['_type' => 'post', 'title' => 'Foo']]); 24 | $this->assertEquals(['create' => ['_type' => 'post', 'title' => 'Foo']], $transaction->serialize()); 25 | } 26 | 27 | /** 28 | * @expectedException Sanity\Exception\ConfigException 29 | * @expectedExceptionMessage `mutate()` method 30 | */ 31 | public function testThrowsWhenCallingCommitWithoutClientContext() 32 | { 33 | $transaction = new Transaction(); 34 | $transaction->create(['_type' => 'post']); 35 | $transaction->commit(); 36 | } 37 | 38 | public function testCanAddCreateMutation() 39 | { 40 | $transaction = new Transaction(); 41 | $this->assertSame($transaction, $transaction->create(['_type' => 'post'])); 42 | $this->assertEquals(['create' => ['_type' => 'post']], $transaction->serialize()[0]); 43 | } 44 | 45 | public function testCanAddCreateIfNotExistsMutation() 46 | { 47 | $transaction = new Transaction(); 48 | $doc = ['_id' => 'someId', '_type' => 'post']; 49 | $this->assertSame($transaction, $transaction->createIfNotExists($doc)); 50 | $this->assertEquals(['createIfNotExists' => $doc], $transaction->serialize()[0]); 51 | } 52 | 53 | public function testCanAddCreateOrReplaceMutation() 54 | { 55 | $transaction = new Transaction(); 56 | $doc = ['_id' => 'someId', '_type' => 'post']; 57 | $this->assertSame($transaction, $transaction->createOrReplace($doc)); 58 | $this->assertEquals(['createOrReplace' => $doc], $transaction->serialize()[0]); 59 | } 60 | 61 | public function testCanAddDeleteMutation() 62 | { 63 | $transaction = new Transaction(); 64 | $this->assertSame($transaction, $transaction->delete('abc123')); 65 | $this->assertEquals(['delete' => ['id' => 'abc123']], $transaction->serialize()[0]); 66 | } 67 | 68 | public function testCanAddDeleteMutationWithSelection() 69 | { 70 | $selection = new Selection('abc123'); 71 | $transaction = new Transaction(); 72 | $this->assertSame($transaction, $transaction->delete($selection)); 73 | $this->assertEquals(['delete' => ['id' => 'abc123']], $transaction->serialize()[0]); 74 | } 75 | 76 | public function testCanAddPatchMutationWithOperationsArray() 77 | { 78 | $transaction = new Transaction(); 79 | $this->assertSame($transaction, $transaction->patch('abc123', ['inc' => ['count' => 1]])); 80 | $this->assertEquals(['patch' => ['id' => 'abc123', 'inc' => ['count' => 1]]], $transaction->serialize()[0]); 81 | } 82 | 83 | public function testCanAddPatchMutationWithPatchInstance() 84 | { 85 | $patch = new Patch('abc123', ['dec' => ['count' => 1]]); 86 | $transaction = new Transaction(); 87 | $this->assertSame($transaction, $transaction->patch($patch)); 88 | $this->assertEquals(['patch' => ['id' => 'abc123', 'dec' => ['count' => 1]]], $transaction->serialize()[0]); 89 | } 90 | 91 | /** 92 | * @expectedException Sanity\Exception\InvalidArgumentException 93 | * @expectedExceptionMessage instantiated patch or an array 94 | */ 95 | public function testThrowsWhenCallingPatchWithInvalidArgs() 96 | { 97 | $transaction = new Transaction(); 98 | $this->assertSame($transaction, $transaction->patch('abc123')); 99 | } 100 | 101 | public function testCanResetPatch() 102 | { 103 | $transaction = new Transaction(); 104 | $this->assertSame($transaction, $transaction->create(['_type' => 'post'])); 105 | $this->assertEquals( 106 | [['create' => ['_type' => 'post']]], 107 | $transaction->serialize() 108 | ); 109 | 110 | $this->assertSame($transaction, $transaction->reset()); 111 | $this->assertEquals([], $transaction->serialize()); 112 | } 113 | 114 | public function testCanJsonEncodePatch() 115 | { 116 | $transaction = new Transaction(); 117 | $this->assertSame($transaction, $transaction->create(['_type' => 'post'])); 118 | $this->assertEquals( 119 | json_encode([['create' => ['_type' => 'post']]]), 120 | json_encode($transaction) 121 | ); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /test/bootstrap.php: -------------------------------------------------------------------------------- 1 | alert('I am haxor!');" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/dangerous-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "I am 1337 ", 8 | "marks": [] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/document.json: -------------------------------------------------------------------------------- 1 | { 2 | "body": [ 3 | { 4 | "_type": "block", 5 | "spans": [ 6 | { 7 | "_type": "span", 8 | "marks": [], 9 | "text": "Integer leo sapien, aliquet nec sodales et, fermentum id arcu. Sed vitae fermentum erat. Cras vitae fermentum neque. Nunc condimentum justo ut est rutrum, nec varius lectus aliquam. Nunc vulputate nunc scelerisque, pulvinar odio quis, pulvinar tortor. Mauris iaculis enim non nibh condimentum bibendum. Proin imperdiet ligula sed neque laoreet gravida. Proin non lorem a leo venenatis efficitur sit amet et arcu. Suspendisse potenti. Praesent tempus vitae elit vel blandit. Vestibulum sollicitudin metus vel urna sollicitudin egestas." 10 | } 11 | ], 12 | "style": "normal" 13 | }, 14 | { 15 | "_type": "block", 16 | "spans": [ 17 | { 18 | "_type": "span", 19 | "marks": [], 20 | "text": "Maecenas massa dui, pretium ac quam sed, euismod viverra risus. Nam vehicula, libero eget tincidunt ullamcorper, nibh mauris auctor ex, quis vulputate felis massa ac libero. Praesent eget auctor justo. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas nec purus vel magna pellentesque aliquet. Vestibulum sit amet enim nec nulla tempus maximus. Proin maximus elementum maximus. Pellentesque quis interdum nisl. " 21 | } 22 | ], 23 | "style": "normal" 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /test/fixtures/empty.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-php/3a0c437333d00388fc7c086a873b79f800398eda/test/fixtures/empty.txt -------------------------------------------------------------------------------- /test/fixtures/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sanity-io/sanity-php/3a0c437333d00388fc7c086a873b79f800398eda/test/fixtures/favicon.png -------------------------------------------------------------------------------- /test/fixtures/h2-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "h2", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "Such h2 header, much amaze", 8 | "marks": [] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/image-with-caption.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "style": "normal", 4 | "_type": "block", 5 | "_key": "bd73ec5f61a1", 6 | "markDefs": [], 7 | "children": [ 8 | { 9 | "_type": "span", 10 | "text": "Also, images are pretty common.", 11 | "marks": [] 12 | } 13 | ] 14 | }, 15 | { 16 | "_type": "image", 17 | "_key": "d234a4fa317a", 18 | "caption": "Now ==> THIS <== is a caption", 19 | "asset": { 20 | "_type": "reference", 21 | "_ref": "image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg" 22 | } 23 | } 24 | ] 25 | -------------------------------------------------------------------------------- /test/fixtures/images.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "style": "normal", 4 | "_type": "block", 5 | "_key": "bd73ec5f61a1", 6 | "markDefs": [], 7 | "children": [ 8 | { 9 | "_type": "span", 10 | "text": "Also, images are pretty common.", 11 | "marks": [] 12 | } 13 | ] 14 | }, 15 | { 16 | "_type": "image", 17 | "_key": "d234a4fa317a", 18 | "asset": { 19 | "_type": "reference", 20 | "_ref": "image-YiOKD0O6AdjKPaK24WtbOEv0-3456x2304-jpg" 21 | } 22 | } 23 | ] -------------------------------------------------------------------------------- /test/fixtures/italicized-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "String with an ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "italicized", 13 | "marks": [ 14 | "em" 15 | ] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": " word.", 20 | "marks": [] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /test/fixtures/link-author-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "String before link ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "actual link text", 13 | "marks": [], 14 | "link": { 15 | "href": "http://icanhas.cheezburger.com/" 16 | }, 17 | "author": { 18 | "name": "Test Testesen" 19 | } 20 | }, 21 | { 22 | "_type": "span", 23 | "text": " the rest", 24 | "marks": [] 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /test/fixtures/link-messy-text-new.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "markDefs": [ 5 | { 6 | "_type": "link", 7 | "_key": "zomgLink", 8 | "href": "http://icanhas.cheezburger.com/" 9 | } 10 | ], 11 | "children": [ 12 | { 13 | "_type": "span", 14 | "text": "String with link to ", 15 | "marks": [] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": "internet ", 20 | "marks": [ 21 | "zomgLink" 22 | ] 23 | }, 24 | { 25 | "_type": "span", 26 | "text": "is very strong and emphasis", 27 | "marks": [ 28 | "zomgLink", 29 | "strong", 30 | "em" 31 | ] 32 | }, 33 | { 34 | "_type": "span", 35 | "text": " and just emphasis", 36 | "marks": [ 37 | "em", 38 | "zomgLink" 39 | ] 40 | }, 41 | { 42 | "_type": "span", 43 | "text": ".", 44 | "marks": [] 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /test/fixtures/link-messy-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "String with link to ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "internet ", 13 | "marks": [], 14 | "link": { 15 | "href": "http://icanhas.cheezburger.com/" 16 | } 17 | }, 18 | { 19 | "_type": "span", 20 | "text": "is very strong and emphasis", 21 | "marks": [ 22 | "strong", 23 | "em" 24 | ], 25 | "link": { 26 | "href": "http://icanhas.cheezburger.com/" 27 | } 28 | }, 29 | { 30 | "_type": "span", 31 | "text": " and just emphasis", 32 | "marks": [ 33 | "em" 34 | ], 35 | "link": { 36 | "href": "http://icanhas.cheezburger.com/" 37 | } 38 | }, 39 | { 40 | "_type": "span", 41 | "text": ".", 42 | "marks": [] 43 | } 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /test/fixtures/link-simple-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "String before link ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "actual link text", 13 | "marks": [], 14 | "link": { 15 | "href": "http://icanhas.cheezburger.com/" 16 | } 17 | }, 18 | { 19 | "_type": "span", 20 | "text": " the rest", 21 | "marks": [] 22 | } 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/list-both-types-blocks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_type": "block", 4 | "listItem": "bullet", 5 | "style": "normal", 6 | "spans": [ 7 | { 8 | "_type": "span", 9 | "text": "A single bulleted item", 10 | "marks": [] 11 | } 12 | ] 13 | }, 14 | { 15 | "_type": "block", 16 | "listItem": "number", 17 | "style": "normal", 18 | "spans": [ 19 | { 20 | "_type": "span", 21 | "text": "First numbered", 22 | "marks": [] 23 | } 24 | ] 25 | }, 26 | { 27 | "_type": "block", 28 | "listItem": "number", 29 | "style": "normal", 30 | "spans": [ 31 | { 32 | "_type": "span", 33 | "text": "Second numbered", 34 | "marks": [] 35 | } 36 | ] 37 | }, 38 | { 39 | "_type": "block", 40 | "listItem": "bullet", 41 | "style": "normal", 42 | "spans": [ 43 | { 44 | "_type": "span", 45 | "text": "A bullet with", 46 | "marks": [] 47 | }, 48 | { 49 | "_type": "span", 50 | "text": "something strong", 51 | "marks": [ 52 | "strong" 53 | ] 54 | } 55 | ] 56 | } 57 | ] 58 | -------------------------------------------------------------------------------- /test/fixtures/list-bulleted-blocks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_type": "block", 4 | "listItem": "bullet", 5 | "style": "normal", 6 | "spans": [ 7 | { 8 | "_type": "span", 9 | "text": "I am the most", 10 | "marks": [] 11 | } 12 | ] 13 | }, 14 | { 15 | "_type": "block", 16 | "listItem": "bullet", 17 | "style": "normal", 18 | "spans": [ 19 | { 20 | "_type": "span", 21 | "text": "expressive", 22 | "marks": [] 23 | }, 24 | { 25 | "_type": "span", 26 | "text": "programmer", 27 | "marks": [ 28 | "strong" 29 | ] 30 | }, 31 | { 32 | "_type": "span", 33 | "text": "you know.", 34 | "marks": [] 35 | } 36 | ] 37 | }, 38 | { 39 | "_type": "block", 40 | "listItem": "bullet", 41 | "style": "normal", 42 | "spans": [ 43 | { 44 | "_type": "span", 45 | "text": "SAD!", 46 | "marks": [] 47 | } 48 | ] 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /test/fixtures/list-numbered-blocks.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_type": "block", 4 | "listItem": "number", 5 | "style": "normal", 6 | "spans": [ 7 | { 8 | "_type": "span", 9 | "text": "One", 10 | "marks": [] 11 | } 12 | ] 13 | }, 14 | { 15 | "_type": "block", 16 | "listItem": "number", 17 | "style": "normal", 18 | "spans": [ 19 | { 20 | "_type": "span", 21 | "text": "Two has ", 22 | "marks": [] 23 | }, 24 | { 25 | "_type": "span", 26 | "text": "bold", 27 | "marks": [ 28 | "strong" 29 | ] 30 | }, 31 | { 32 | "_type": "span", 33 | "text": " word", 34 | "marks": [] 35 | } 36 | ] 37 | }, 38 | { 39 | "_type": "block", 40 | "listItem": "number", 41 | "style": "h2", 42 | "spans": [ 43 | { 44 | "_type": "span", 45 | "text": "Three", 46 | "marks": [] 47 | } 48 | ] 49 | } 50 | ] 51 | -------------------------------------------------------------------------------- /test/fixtures/marks-ordered-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "Normal", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "strong", 13 | "marks": [ 14 | "strong" 15 | ] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": "strong and underline", 20 | "marks": [ 21 | "strong", 22 | "underline" 23 | ] 24 | }, 25 | { 26 | "_type": "span", 27 | "text": "strong and underline and emphasis", 28 | "marks": [ 29 | "strong", 30 | "underline", 31 | "em" 32 | ] 33 | }, 34 | { 35 | "_type": "span", 36 | "text": "underline and emphasis", 37 | "marks": [ 38 | "underline", 39 | "em" 40 | ] 41 | }, 42 | { 43 | "_type": "span", 44 | "text": "normal again", 45 | "marks": [] 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /test/fixtures/marks-reordered-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "Normal", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "strong", 13 | "marks": [ 14 | "strong" 15 | ] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": "strong and underline", 20 | "marks": [ 21 | "underline", 22 | "strong" 23 | ] 24 | }, 25 | { 26 | "_type": "span", 27 | "text": "strong and underline and emphasis", 28 | "marks": [ 29 | "em", 30 | "strong", 31 | "underline" 32 | ] 33 | }, 34 | { 35 | "_type": "span", 36 | "text": "underline and emphasis", 37 | "marks": [ 38 | "em", 39 | "underline" 40 | ] 41 | }, 42 | { 43 | "_type": "span", 44 | "text": "normal again", 45 | "marks": [] 46 | } 47 | ] 48 | } 49 | -------------------------------------------------------------------------------- /test/fixtures/messy-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "Hacking ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "teh codez", 13 | "marks": [ 14 | "code" 15 | ] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": " is ", 20 | "marks": [] 21 | }, 22 | { 23 | "_type": "span", 24 | "text": "all ", 25 | "marks": [ 26 | "strong" 27 | ] 28 | }, 29 | { 30 | "_type": "span", 31 | "text": "fun", 32 | "marks": [ 33 | "underline", 34 | "strong" 35 | ] 36 | }, 37 | { 38 | "_type": "span", 39 | "text": " and ", 40 | "marks": [ 41 | "strong" 42 | ] 43 | }, 44 | { 45 | "_type": "span", 46 | "text": "games", 47 | "marks": [ 48 | "em", 49 | "strong" 50 | ] 51 | }, 52 | { 53 | "_type": "span", 54 | "text": " until", 55 | "marks": [ 56 | "strong" 57 | ] 58 | }, 59 | { 60 | "_type": "span", 61 | "text": " someone gets p0wn3d.", 62 | "marks": [] 63 | } 64 | ] 65 | } 66 | -------------------------------------------------------------------------------- /test/fixtures/non-block.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "author", 3 | "name": "Test Person" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/normal-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "Normal string of text.", 8 | "marks": [] 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /test/fixtures/underlined-text.json: -------------------------------------------------------------------------------- 1 | { 2 | "_type": "block", 3 | "style": "normal", 4 | "spans": [ 5 | { 6 | "_type": "span", 7 | "text": "String with an ", 8 | "marks": [] 9 | }, 10 | { 11 | "_type": "span", 12 | "text": "underlined", 13 | "marks": [ 14 | "underline" 15 | ] 16 | }, 17 | { 18 | "_type": "span", 19 | "text": " word.", 20 | "marks": [] 21 | } 22 | ] 23 | } 24 | --------------------------------------------------------------------------------