diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..5175c803 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.php] +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..1f459efa --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +* text=auto eol=lf + +*.md diff=markdown +*.php diff=php + +/.github export-ignore +/tests export-ignore +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +phpstan.neon export-ignore +phpunit.xml export-ignore +pint.json export-ignore +testbench.yaml export-ignore diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..845bc8c0 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,100 @@ +name: Testing + +on: + push: + schedule: + - cron: '0 0 * * 1' # run tests on every week Monday + +jobs: + static_analyze: + name: Static Analyze + + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP with PECL extension + uses: shivammathur/setup-php@v2 + with: + php-version: 8.3 + coverage: xdebug + + - name: Get composer cache directory + run: echo "COMPOSER_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ env.COMPOSER_DIR }} + key: ${{ runner.os }}-composer-static-analyze-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-static-analyze- + + - name: Install dependencies + run: composer update --no-progress --no-interaction + + - name: Check runtime dependencies + run: composer check-platform-reqs + + - name: Run composer validate + run: composer validate --strict + + - name: Run composer normalize + run: composer normalize --dry-run + + - name: Run static analysis + run: vendor/bin/phpstan --memory-limit=-1 --verbose + + - name: Run coding style checker + run: vendor/bin/pint -v --test + + - name: Run type coverage check + run: vendor/bin/pest --memory-limit=-1 --type-coverage --min=95 + + testing: + name: PHP ${{ matrix.php }} + + runs-on: ubuntu-latest + + strategy: + fail-fast: false + + matrix: + php: [ '8.3', '8.2' ] + + services: + typesense: + image: typesense/typesense:0.25.2 + ports: + - 8108:8108/tcp + volumes: + - typesense_data:/data + env: + TYPESENSE_DATA_DIR: /data + TYPESENSE_API_KEY: testing + TYPESENSE_ENABLE_CORS: true + + steps: + - uses: actions/checkout@v4 + + - name: Setup PHP with PECL extension + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: xdebug + + - name: Get composer cache directory + run: echo "COMPOSER_DIR=$(composer config cache-files-dir)" >> $GITHUB_ENV + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: ${{ env.COMPOSER_DIR }} + key: ${{ runner.os }}-composer-php-${{ matrix.php }}-${{ hashFiles('**/composer.json') }} + restore-keys: ${{ runner.os }}-composer-php-${{ matrix.php }}- + + - name: Install dependencies + run: composer update --no-progress --no-interaction + + - name: Run tests + run: vendor/bin/pest --coverage --min=95 diff --git a/.gitignore b/.gitignore index 8228c840..4d7bb9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,11 @@ -.idea -.tmp -/composer.lock -vendor +/.fleet +/.idea +/.vscode +/coverage +/vendor +.DS_Store +.phpunit.result.cache +clover.xml +composer.phar +composer.lock +Thumbs.db \ No newline at end of file diff --git a/composer.json b/composer.json index 757297e7..3d4f9943 100644 --- a/composer.json +++ b/composer.json @@ -1,9 +1,8 @@ { "name": "typesense/typesense-php", "description": "PHP client for Typesense Search Server: https://github.com/typesense/typesense", - "type": "library", - "homepage": "https://github.com/typesense/typesense-php", "license": "Apache-2.0", + "type": "library", "authors": [ { "name": "Typesense", @@ -16,48 +15,61 @@ "email": "abdullah@devloops.net", "homepage": "https://www.devloops.net", "role": "Developer" + }, + { + "name": "bepsvpt", + "email": "6ibrl@cpp.tw" } ], + "homepage": "https://github.com/typesense/typesense-php", "support": { - "docs": "https://typesense.org/api", + "issues": "https://github.com/typesense/typesense-php/issues", "source": "https://github.com/typesense/typesense-php", - "issues": "https://github.com/typesense/typesense-php/issues" + "docs": "https://typesense.org/docs/api" + }, + "require": { + "php": "^8.2", + "php-http/discovery": "^1.19", + "psr/http-client": "^1.0", + "psr/http-client-implementation": "^1.0", + "psr/http-factory": "^1.0", + "psr/http-factory-implementation": "^1.0" + }, + "require-dev": { + "ergebnis/composer-normalize": "^2.42", + "guzzlehttp/guzzle": "^7.8", + "laravel/pint": "^1.14", + "pestphp/pest": "^2.34", + "pestphp/pest-plugin-faker": "^2.0", + "pestphp/pest-plugin-type-coverage": "^2.8", + "phpstan/phpstan": "^1.10", + "symfony/http-client": "^7.0" }, "minimum-stability": "stable", + "prefer-stable": true, "autoload": { "psr-4": { "Typesense\\": "src/" } }, - "require": { - "php": ">=7.4", - "ext-json": "*", - "monolog/monolog": "^2.1 || ^3.0 || ^3.3", - "nyholm/psr7": "^1.3", - "php-http/client-common": "^1.0 || ^2.3", - "php-http/discovery": "^1.0", - "php-http/httplug": "^1.0 || ^2.2", - "psr/http-client-implementation": "^1.0", - "psr/http-message": "^1.0 || ^2.0", - "psr/http-factory": "^1.0" - }, - "require-dev": { - "squizlabs/php_codesniffer": "3.*", - "symfony/http-client": "^5.2" + "autoload-dev": { + "psr-4": { + "Typesense\\Tests\\": "tests/" + } }, "config": { - "optimize-autoloader": true, - "preferred-install": { - "*": "dist" + "allow-plugins": { + "ergebnis/composer-normalize": true, + "pestphp/pest-plugin": true, + "php-http/discovery": false }, + "optimize-autoloader": true, "sort-packages": true }, "scripts": { - "typesenseServer": [ + "typesense-server": [ "Composer\\Config::disableProcessTimeout", - "docker-compose up" - ], - "lint": "phpcs -v", - "lint:fix": "phpcbf" + "docker-compose pull && docker-compose up" + ] } } diff --git a/docker-compose.yml b/docker-compose.yml index 41be3e38..9260c431 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,16 @@ -version: '3.5' +version: "3" services: - typesense: - image: typesense/typesense:0.21.0.rc20 - environment: - TYPESENSE_DATA_DIR: /data - TYPESENSE_API_KEY: xyz - volumes: - - /tmp/typesense-server-data:/data - ports: - - 8108:8108 - restart: "no" + typesense: + image: typesense/typesense:0.25.2 + container_name: typesense-testing + restart: "on-failure" + ports: + - "8108:8108/tcp" + environment: + TYPESENSE_DATA_DIR: "/var/tmp" + TYPESENSE_API_KEY: "testing" + TYPESENSE_ENABLE_CORS: "true" + TYPESENSE_PEERING_ADDRESS: "127.0.0.1" + TYPESENSE_PEERING_PORT: "12345" + TYPESENSE_PEERING_SUBNET: "127.0.0.1/24" diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index 91e87432..00000000 --- a/examples/README.md +++ /dev/null @@ -1,14 +0,0 @@ -### Running the examples - -Start a local typesense server via docker: - -```shell script -composer run-script typesenseServer -``` - -Then: - -```shell script -cd examples -php <example_name>.php -``` \ No newline at end of file diff --git a/examples/alias_operations.php b/examples/alias_operations.php deleted file mode 100644 index 47f51e81..00000000 --- a/examples/alias_operations.php +++ /dev/null @@ -1,142 +0,0 @@ -<?php - -/** @noinspection ForgottenDebugOutputInspection */ - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - try { - print_r($client->aliases['books']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - try { - print_r($client->collections['books_january']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - echo "--------Create Collection-------\n"; - print_r( - $client->collections->create( - [ - 'name' => 'books_january', - 'fields' => [ - [ - 'name' => 'title', - 'type' => 'string', - ], - [ - 'name' => 'authors', - 'type' => 'string[]', - ], - [ - 'name' => 'authors_facet', - 'type' => 'string[]', - 'facet' => true, - ], - [ - 'name' => 'publication_year', - 'type' => 'int32', - ], - [ - 'name' => 'publication_year_facet', - 'type' => 'string', - 'facet' => true, - ], - [ - 'name' => 'ratings_count', - 'type' => 'int32', - ], - [ - 'name' => 'average_rating', - 'type' => 'float', - ], - [ - 'name' => 'image_url', - 'type' => 'string', - ], - ], - 'default_sorting_field' => 'ratings_count', - ] - ) - ); - echo "--------Create Collection-------\n"; - echo "\n"; - echo "--------Create Collection Alias-------\n"; - print_r( - $client->aliases->upsert( - 'books', - [ - 'collection_name' => 'books_january', - ] - ) - ); - echo "--------Create Collection Alias-------\n"; - echo "\n"; - echo "--------Create Document on Alias-------\n"; - print_r( - $client->collections['books']->documents->create( - [ - 'id' => '1', - 'original_publication_year' => 2008, - 'authors' => [ - 'Suzanne Collins', - ], - 'average_rating' => 4.34, - 'publication_year' => 2008, - 'publication_year_facet' => '2008', - 'authors_facet' => [ - 'Suzanne Collins', - ], - 'title' => 'The Hunger Games', - 'image_url' => 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', - 'ratings_count' => 4780653, - ] - ) - ); - echo "--------Create Document on Alias-------\n"; - echo "\n"; - echo "--------Search Document on Alias-------\n"; - print_r( - $client->collections['books']->documents->search( - [ - 'q' => 'hunger', - 'query_by' => 'title', - 'sort_by' => 'ratings_count:desc', - ] - ) - ); - echo "--------Search Document on Alias-------\n"; - echo "\n"; - echo "--------Retrieve All Aliases-------\n"; - print_r($client->aliases->retrieve()); - echo "--------Retrieve All Aliases-------\n"; - echo "\n"; - echo "--------Retrieve All Alias Documents-------\n"; - print_r($client->aliases['books']->retrieve()); - echo "--------Retrieve All Alias Documents-------\n"; - echo "\n"; - echo "--------Delete Alias-------\n"; - print_r($client->aliases['books']->delete()); - echo "--------Delete Alias-------\n"; - echo "\n"; -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/cluster_operations.php b/examples/cluster_operations.php deleted file mode 100644 index 98893507..00000000 --- a/examples/cluster_operations.php +++ /dev/null @@ -1,29 +0,0 @@ -<?php - -/** @noinspection ForgottenDebugOutputInspection */ - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - - print_r($client->operations->perform('snapshot', ['snapshot_path' => '/tmp/snapshot'])); -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/collection_operations.php b/examples/collection_operations.php deleted file mode 100644 index 7f892c0e..00000000 --- a/examples/collection_operations.php +++ /dev/null @@ -1,232 +0,0 @@ -<?php - -/** @noinspection ForgottenDebugOutputInspection */ - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - - try { - print_r($client->collections['books']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - - echo "--------Create Collection-------\n"; - print_r( - $client->collections->create( - [ - 'name' => 'books', - 'fields' => [ - [ - 'name' => 'title', - 'type' => 'string', - ], - [ - 'name' => 'authors', - 'type' => 'string[]', - ], - [ - 'name' => 'authors_facet', - 'type' => 'string[]', - 'facet' => true, - ], - [ - 'name' => 'publication_year', - 'type' => 'int32', - ], - [ - 'name' => 'publication_year_facet', - 'type' => 'string', - 'facet' => true, - ], - [ - 'name' => 'ratings_count', - 'type' => 'int32', - ], - [ - 'name' => 'average_rating', - 'type' => 'float', - ], - [ - 'name' => 'image_url', - 'type' => 'string', - ], - ], - 'default_sorting_field' => 'ratings_count', - ] - ) - ); - echo "--------Create Collection-------\n"; - echo "\n"; - echo "--------Retrieve Collection-------\n"; - print_r($client->collections['books']->retrieve()); - echo "--------Retrieve Collection-------\n"; - echo "\n"; - echo "--------Retrieve All Collections-------\n"; - print_r($client->collections->retrieve()); - echo "--------Retrieve All Collections-------\n"; - echo "\n"; - echo "--------Create Document-------\n"; - print_r( - $client->collections['books']->documents->create( - [ - 'id' => '1', - 'original_publication_year' => 2008, - 'authors' => [ - 'Suzanne Collins', - ], - 'average_rating' => 4.34, - 'publication_year' => 2008, - 'publication_year_facet' => '2008', - 'authors_facet' => [ - 'Suzanne Collins', - ], - 'title' => 'The Hunger Games', - 'image_url' => 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', - 'ratings_count' => 4780653, - ] - ) - ); - echo "--------Create Document-------\n"; - echo "\n"; - - echo "--------Upsert Document-------\n"; - print_r( - $client->collections['books']->documents->upsert( - [ - 'id' => '1', - 'original_publication_year' => 2008, - 'authors' => [ - 'Suzanne Collins', - ], - 'average_rating' => 4.6, - 'publication_year' => "2008", - 'publication_year_facet' => '2008', - 'authors_facet' => [ - 'Suzanne Collins', - ], - 'title' => 'The Hunger Games', - 'image_url' => 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', - 'ratings_count' => 4780653, - ], - [ - 'dirty_values' => 'coerce_or_reject', - ] - ) - ); - echo "--------Upsert Document-------\n"; - echo "\n"; - - echo "--------Export Documents-------\n"; - $exportedDocStrs = $client->collections['books']->documents->export(["exclude_fields" => "authors_facet"]); - print_r($exportedDocStrs); - echo "--------Export Documents-------\n"; - echo "\n"; - echo "--------Update Single Document-------\n"; - print_r($client->collections['books']->documents['1']->update([ - 'average_rating' => 4.5, - ])); - echo "--------Update Single Document-------\n"; - echo "\n"; - echo "--------Fetch Single Document-------\n"; - print_r($client->collections['books']->documents['1']->retrieve()); - echo "--------Fetch Single Document-------\n"; - echo "\n"; - echo "--------Search Document-------\n"; - print_r( - $client->collections['books']->documents->search( - [ - 'q' => 'hunger', - 'query_by' => 'title', - 'sort_by' => 'ratings_count:desc', - ] - ) - ); - echo "--------Search Document-------\n"; - echo "\n"; - echo "--------Multi search-------\n"; - print_r( - $client->multiSearch->perform( - [ - 'searches' => [ - [ - 'q' => 'hunger', - 'sort_by' => 'ratings_count:desc', - ], - [ - 'q' => 'game', - 'sort_by' => 'ratings_count:asc', - ] - ] - ], - [ - 'query_by' => 'title', - 'collection' => 'books' - ] - ) - ); - echo "--------Multi Search-------\n"; - echo "\n"; - echo "--------Delete Document-------\n"; - print_r($client->collections['books']->documents['1']->delete()); - echo "--------Delete Document-------\n"; - echo "\n"; - echo "--------Import Documents-------\n"; - $docsToImport = []; - $exportedDocStrsArray = explode('\n', $exportedDocStrs); - foreach ($exportedDocStrsArray as $exportedDocStr) { - $docsToImport[] = json_decode($exportedDocStr, true); - } - $importRes = - $client->collections['books']->documents->import($docsToImport); - print_r($importRes); - - // Or if you have documents in JSONL format, and want to save the overhead of parsing JSON, - // you can also pass in a JSONL string of documents - // $client->collections['books']->documents->import($exportedDocStrsArray); - echo "--------Import Documents-------\n"; - echo "\n"; - echo "--------Upsert Documents-------\n"; - $upsertRes = - $client->collections['books']->documents->import($docsToImport, [ - 'action' => 'upsert' - ]); - print_r($upsertRes); - echo "--------Upsert Documents-------\n"; - echo "\n"; - echo "--------Update Documents-------\n"; - $upsertRes = - $client->collections['books']->documents->import($docsToImport, [ - 'action' => 'update' - ]); - print_r($upsertRes); - echo "--------Upsert Documents-------\n"; - echo "\n"; - echo "--------Bulk Delete Documents-------\n"; - print_r($client->collections['books']->documents->delete(['filter_by' => 'publication_year:=2008'])); - echo "--------Bulk Delete Documents-------\n"; - echo "\n"; - echo "--------Delete Collection-------\n"; - print_r($client->collections['books']->delete()); - echo "--------Delete Collection-------\n"; -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/curation_operations.php b/examples/curation_operations.php deleted file mode 100644 index 13c3874c..00000000 --- a/examples/curation_operations.php +++ /dev/null @@ -1,148 +0,0 @@ -<?php - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - try { - print_r($client->collections['books']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - echo "--------Create Collection-------\n"; - print_r( - $client->collections->create( - [ - 'name' => 'books', - 'fields' => [ - [ - 'name' => 'title', - 'type' => 'string', - ], - [ - 'name' => 'authors', - 'type' => 'string[]', - ], - [ - 'name' => 'authors_facet', - 'type' => 'string[]', - 'facet' => true, - ], - [ - 'name' => 'publication_year', - 'type' => 'int32', - ], - [ - 'name' => 'publication_year_facet', - 'type' => 'string', - 'facet' => true, - ], - [ - 'name' => 'ratings_count', - 'type' => 'int32', - ], - [ - 'name' => 'average_rating', - 'type' => 'float', - ], - [ - 'name' => 'image_url', - 'type' => 'string', - ], - ], - 'default_sorting_field' => 'ratings_count', - ] - ) - ); - echo "--------Create Collection-------\n"; - echo "\n"; - echo "--------Create or Update Override-------\n"; - print_r( - $client->collections['books']->overrides->upsert( - 'hermione-exact', - [ - 'rule' => [ - 'query' => 'hermione', - 'match' => 'exact', - ], - 'includes' => [ - [ - 'id' => '1', - 'position' => 1, - ], - ], - ] - ) - ); - echo "--------Create or Update Override-------\n"; - echo "\n"; - echo "--------Get All Overrides-------\n"; - print_r($client->collections['books']->overrides->retrieve()); - echo "--------Get All Overrides-------\n"; - echo "\n"; - echo "--------Get Single Override-------\n"; - print_r( - $client->collections['books']->overrides['hermione-exact']->retrieve() - ); - echo "--------Get Single Override-------\n"; - echo "\n"; - echo "--------Create Document-------\n"; - print_r( - $client->collections['books']->documents->create( - [ - 'id' => '1', - 'original_publication_year' => 2008, - 'authors' => [ - 'Suzanne Collins', - ], - 'average_rating' => 4.34, - 'publication_year' => 2008, - 'publication_year_facet' => '2008', - 'authors_facet' => [ - 'Suzanne Collins', - ], - 'title' => 'The Hunger Games', - 'image_url' => 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', - 'ratings_count' => 4780653, - ] - ) - ); - echo "--------Create Document-------\n"; - echo "\n"; - echo "--------Search Document-------\n"; - print_r( - $client->collections['books']->documents->search( - [ - 'q' => 'hermione', - 'query_by' => 'title', - 'sort_by' => 'ratings_count:desc', - ] - ) - ); - echo "--------Search Document-------\n"; - echo "\n"; - echo "--------Delete Override-------\n"; - print_r( - $client->collections['books']->getOverrides()['hermione-exact']->delete() - ); - echo "--------Delete Override-------\n"; - echo "\n"; -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/info_operations.php b/examples/info_operations.php deleted file mode 100644 index ba00a3e8..00000000 --- a/examples/info_operations.php +++ /dev/null @@ -1,31 +0,0 @@ -<?php - -/** @noinspection ForgottenDebugOutputInspection */ - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - - print_r($client->debug->retrieve()); - print_r($client->metrics->retrieve()); - print_r($client->health->retrieve()); -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/keys_operations.php b/examples/keys_operations.php deleted file mode 100644 index b4dcc9c8..00000000 --- a/examples/keys_operations.php +++ /dev/null @@ -1,160 +0,0 @@ -<?php - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - try { - print_r($client->collections['users']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - echo "--------Create Collection-------\n"; - print_r( - $client->collections->create( - [ - 'name' => 'users', - 'fields' => [ - [ - 'name' => 'company_id', - 'type' => 'int32', - 'facet' => false - ], - [ - 'name' => 'user_name', - 'type' => 'string', - 'facet' => false - ], - [ - 'name' => 'login_count', - 'type' => 'int32', - 'facet' => false - ], - [ - 'name' => 'country', - 'type' => 'string', - 'facet' => true - ] - ], - 'default_sorting_field' => 'company_id' - ] - ) - ); - echo "--------Create Collection-------\n"; - echo "\n"; - echo "--------Create Documents-------\n"; - print_r( - $client->collections['users']->documents->createMany([ - [ - 'company_id' => 124, - 'user_name' => 'Hilary Bradford', - 'login_count' => 10, - 'country' => 'USA' - ], - [ - 'company_id' => 124, - 'user_name' => 'Nile Carty', - 'login_count' => 100, - 'country' => 'USA' - ], - [ - 'company_id' => 126, - 'user_name' => 'Tahlia Maxwell', - 'login_count' => 1, - 'country' => 'France' - ], - [ - 'company_id' => 126, - 'user_name' => 'Karl Roy', - 'login_count' => 2, - 'country' => 'Germany' - ] - ]) - ); - echo "--------Create Documents-------\n"; - echo "\n"; - echo "--------Create a search only API key-------\n"; - $searchOnlyApiKeyResponse = $client->keys->create([ - 'description' => 'Search-only key.', - 'actions' => ['documents:search'], - 'collections' => ['*'] - ]); - print_r($searchOnlyApiKeyResponse); - echo "--------Create a search only API key-------\n"; - echo "\n"; - echo "--------Get All Keys-------\n"; - print_r($client->keys->retrieve()); - echo "--------Get All Keys-------\n"; - echo "\n"; - echo "--------Get Single Key-------\n"; - print_r( - $client->keys[$searchOnlyApiKeyResponse['id']]->retrieve() - ); - echo "--------Get Single Key-------\n"; - echo "\n"; - echo "--------Generate Scoped API Key-------\n"; - $scopedAPIKey = $client->keys->generateScopedSearchKey($searchOnlyApiKeyResponse['value'], ['filter_by' => 'company_id:124']); - print_r($scopedAPIKey); - echo "\n"; - echo "--------Generate Scoped API Key-------\n"; - echo "\n"; - echo "--------Search Documents with scoped Key-------\n"; - $scopedClient = new Client( - [ - 'api_key' => $scopedAPIKey, - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ] - ] - ); - - print_r( - $scopedClient->collections['users']->documents->search( - [ - 'q' => 'Hilary', - 'query_by' => 'user_name' - ] - ) - ); - echo "--------Search Documents with scoped Key-------\n"; - echo "\n"; - echo "--------Search for document outside of scope for scoped Key-------\n"; - print_r( - $scopedClient->collections['users']->documents->search( - [ - 'q' => 'Maxwell', - 'query_by' => 'user_name' - ] - ) - ); - echo "--------Search for document outside of scope for scoped Key-------\n"; - echo "\n"; - echo "--------Delete Key-------\n"; - print_r( - $client->keys[$searchOnlyApiKeyResponse['id']]->delete() - ); - echo "--------Delete Key-------\n"; - echo "\n"; -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/examples/synonym_operations.php b/examples/synonym_operations.php deleted file mode 100644 index a883e8e5..00000000 --- a/examples/synonym_operations.php +++ /dev/null @@ -1,150 +0,0 @@ -<?php - -include '../vendor/autoload.php'; - -use Symfony\Component\HttpClient\HttplugClient; -use Typesense\Client; - -try { - $client = new Client( - [ - 'api_key' => 'xyz', - 'nodes' => [ - [ - 'host' => 'localhost', - 'port' => '8108', - 'protocol' => 'http', - ], - ], - 'client' => new HttplugClient(), - ] - ); - echo '<pre>'; - try { - print_r($client->collections['books']->delete()); - } catch (Exception $e) { - // Don't error out if the collection was not found - } - echo "--------Create Collection-------\n"; - print_r( - $client->collections->create( - [ - 'name' => 'books', - 'fields' => [ - [ - 'name' => 'title', - 'type' => 'string', - ], - [ - 'name' => 'authors', - 'type' => 'string[]', - 'facet' => true - ], - [ - 'name' => 'publication_year', - 'type' => 'int32', - 'facet' => true, - ], - [ - 'name' => 'ratings_count', - 'type' => 'int32', - ], - [ - 'name' => 'average_rating', - 'type' => 'float', - ], - [ - 'name' => 'image_url', - 'type' => 'string', - ], - ], - 'default_sorting_field' => 'ratings_count', - ] - ) - ); - echo "--------Create Collection-------\n"; - echo "\n"; - echo "--------Upsert Synonym-------\n"; - print_r( - $client->collections['books']->synonyms->upsert( - 'synonym-set-1', - [ - 'synonyms' => ['Hunger', 'Katniss'], - ] - ) - ); - echo "--------Upsert Synonym-------\n"; - echo "\n"; - echo "--------Get All Synonyms-------\n"; - print_r($client->collections['books']->synonyms->retrieve()); - echo "--------Get All Synonyms-------\n"; - echo "\n"; - echo "--------Get Single Synonym-------\n"; - print_r( - $client->collections['books']->synonyms['synonym-set-1']->retrieve() - ); - echo "--------Get Single Synonym-------\n"; - echo "\n"; - echo "--------Create Document-------\n"; - print_r( - $client->collections['books']->documents->create( - [ - 'id' => '1', - 'original_publication_year' => 2008, - 'authors' => [ - 'Suzanne Collins', - ], - 'average_rating' => 4.34, - 'publication_year' => 2008, - 'title' => 'The Hunger Games', - 'image_url' => 'https://images.gr-assets.com/books/1447303603m/2767052.jpg', - 'ratings_count' => 4780653, - ] - ) - ); - echo "--------Create Document-------\n"; - echo "\n"; - echo "--------Search Document, using a synonym-------\n"; - print_r( - $client->collections['books']->documents->search( - [ - 'q' => 'Katniss', - 'query_by' => 'title' - ] - ) - ); - echo "--------Search Document, using a synonym-------\n"; - echo "\n"; - echo "--------Upsert 1-way synonym-------\n"; - print_r( - $client->collections['books']->synonyms->upsert( - 'synonym-set-1', - [ - 'root' => 'Katniss', - 'synonyms' => ['Hunger', 'Peeta'], - ] - ) - ); - echo "--------Upsert 1-way synonym-------\n"; - echo "\n"; - echo "--------Search Document, using a synonym-------\n"; - // Won't return any results - print_r( - $client->collections['books']->documents->search( - [ - 'q' => 'Peeta', - 'query_by' => 'title' - ] - ) - ); - echo "--------Search Document, using a synonym-------\n"; - echo "\n"; - echo "--------Delete Synonym-------\n"; - print_r( - $client->collections['books']->getSynonyms()['synonym-set-1']->delete() - ); - echo "--------Delete Synonym-------\n"; - echo "\n"; -} catch (Exception $e) { - echo $e->getMessage(); -} diff --git a/phpcs.xml b/phpcs.xml deleted file mode 100644 index e7ff5dab..00000000 --- a/phpcs.xml +++ /dev/null @@ -1,45 +0,0 @@ -<?xml version="1.0"?> -<ruleset xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" name="PHP_CodeSniffer" - xsi:noNamespaceSchemaLocation="phpcs.xsd"> - <description>The coding standard for PHP_CodeSniffer itself.</description> - - <file>src</file> - <file>examples</file> - - <exclude-pattern>*/src/Standards/*/Tests/*\.(inc|css|js)$</exclude-pattern> - <exclude-pattern>*/tests/Core/*/*\.(inc|css|js)$</exclude-pattern> - - <arg name="basepath" value="."/> - <arg name="colors"/> - <arg name="parallel" value="75"/> - <arg value="np"/> - - <!-- Don't hide tokenizer exceptions --> - <rule ref="Internal.Tokenizer.Exception"> - <type>error</type> - </rule> - - <!-- Include the whole PSR12 standard --> - <rule ref="PSR12"/> - - <!-- Have 12 chars padding maximum and always show as errors --> - <rule ref="Generic.Formatting.MultipleStatementAlignment"> - <properties> - <property name="maxPadding" value="12"/> - <property name="error" value="true"/> - </properties> - </rule> - - <!-- Ban some functions --> - <rule ref="Generic.PHP.ForbiddenFunctions"> - <properties> - <property name="forbiddenFunctions" type="array"> - <element key="sizeof" value="count"/> - <element key="delete" value="unset"/> - <element key="print" value="echo"/> - <element key="is_null" value="null"/> - <element key="create_function" value="null"/> - </property> - </properties> - </rule> -</ruleset> \ No newline at end of file diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 00000000..fb46dc56 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,19 @@ +parameters: + level: max + + parallel: + maximumNumberOfProcesses: 4 + + paths: + - src + - tests + + ignoreErrors: + - + message: '#Undefined variable\: \$this#' + paths: + - tests/* + - + message: '#Call to an undefined method Pest\\PendingCalls\\TestCall\|Pest\\Support\\HigherOrderTapProxy\:\:.+\(\)\.#' + paths: + - tests/* diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 00000000..b8cda39a --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ +<?xml version="1.0" encoding="UTF-8"?> +<phpunit + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd" + bootstrap="vendor/autoload.php" + beStrictAboutChangesToGlobalState="true" + colors="true" + columns="max" + executionOrder="random" + displayDetailsOnTestsThatTriggerWarnings="true" + failOnDeprecation="true" + failOnWarning="true" + failOnNotice="true" + failOnRisky="true" +> + <source> + <include> + <directory>src</directory> + </include> + </source> + <testsuites> + <testsuite name="Test Suite"> + <directory>tests</directory> + </testsuite> + </testsuites> + <coverage> + <report> + <html outputDirectory="coverage" lowUpperBound="80" highLowerBound="95"/> + </report> + </coverage> + <php> + <ini name="memory_limit" value="-1"/> + <ini name="error_reporting" value="-1"/> + <ini name="log_errors_max_len" value="0"/> + <ini name="xdebug.show_exception_trace" value="0"/> + <ini name="assert.exception" value="1"/> + </php> +</phpunit> diff --git a/src/Alias.php b/src/Alias.php deleted file mode 100644 index 5dd76189..00000000 --- a/src/Alias.php +++ /dev/null @@ -1,65 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Alias - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Alias -{ - - /** - * @var string - */ - private string $name; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param string $name - * @param ApiCall $apiCall - */ - public function __construct(string $name, ApiCall $apiCall) - { - $this->name = $name; - $this->apiCall = $apiCall; - } - - /** - * @return string - */ - public function endPointPath(): string - { - return sprintf('%s/%s', Aliases::RESOURCE_PATH, $this->name); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endPointPath()); - } -} diff --git a/src/Aliases.php b/src/Aliases.php deleted file mode 100644 index d1c76cf1..00000000 --- a/src/Aliases.php +++ /dev/null @@ -1,124 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Aliases - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Aliases implements \ArrayAccess -{ - - public const RESOURCE_PATH = '/aliases'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var array - */ - private array $aliases = []; - - /** - * Aliases constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param string $aliasName - * - * @return string - */ - public function endPointPath(string $aliasName): string - { - return sprintf('%s/%s', static::RESOURCE_PATH, $aliasName); - } - - /** - * @param string $name - * @param array $mapping - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function upsert(string $name, array $mapping): array - { - return $this->apiCall->put($this->endPointPath($name), $mapping); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(static::RESOURCE_PATH, []); - } - - /** - * @param $name - * - * @return mixed - */ - public function __get($name) - { - if (isset($this->{$name})) { - return $this->{$name}; - } - - if (!isset($this->aliases[$name])) { - $this->aliases[$name] = new Alias($name, $this->apiCall); - } - - return $this->aliases[$name]; - } - - /** - * @inheritDoc - */ - public function offsetExists($offset): bool - { - return isset($this->aliases[$offset]); - } - - /** - * @inheritDoc - */ - public function offsetGet($offset): Alias - { - if (!isset($this->aliases[$offset])) { - $this->aliases[$offset] = new Alias($offset, $this->apiCall); - } - - return $this->aliases[$offset]; - } - - /** - * @inheritDoc - */ - public function offsetSet($offset, $value): void - { - $this->aliases[$offset] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset($offset): void - { - unset($this->aliases[$offset]); - } -} diff --git a/src/Analytics.php b/src/Analytics.php deleted file mode 100644 index 0adf4fd2..00000000 --- a/src/Analytics.php +++ /dev/null @@ -1,25 +0,0 @@ -<?php - -namespace Typesense; - -class Analytics -{ - const RESOURCE_PATH = '/analytics'; - - private ApiCall $apiCall; - - private AnalyticsRules $rules; - - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - public function rules() - { - if (!isset($this->rules)) { - $this->rules = new AnalyticsRules($this->apiCall); - } - return $this->rules; - } -} diff --git a/src/AnalyticsRule.php b/src/AnalyticsRule.php deleted file mode 100644 index bc8755f6..00000000 --- a/src/AnalyticsRule.php +++ /dev/null @@ -1,30 +0,0 @@ -<?php - -namespace Typesense; - -class AnalyticsRule -{ - private $ruleName; - private ApiCall $apiCall; - - public function __construct(string $ruleName, ApiCall $apiCall) - { - $this->ruleName = $ruleName; - $this->apiCall = $apiCall; - } - - public function retrieve() - { - return $this->apiCall->get($this->endpointPath(), []); - } - - public function delete() - { - return $this->apiCall->delete($this->endpointPath()); - } - - private function endpointPath() - { - return AnalyticsRules::RESOURCE_PATH . '/' . $this->ruleName; - } -} diff --git a/src/AnalyticsRules.php b/src/AnalyticsRules.php deleted file mode 100644 index 412aea0d..00000000 --- a/src/AnalyticsRules.php +++ /dev/null @@ -1,39 +0,0 @@ -<?php - -namespace Typesense; - -class AnalyticsRules -{ - const RESOURCE_PATH = '/analytics/rules'; - - private ApiCall $apiCall; - private $analyticsRules = []; - - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - public function __get($ruleName) - { - if (!isset($this->analyticsRules[$ruleName])) { - $this->analyticsRules[$ruleName] = new AnalyticsRule($ruleName, $this->apiCall); - } - return $this->analyticsRules[$ruleName]; - } - - public function upsert($ruleName, $params) - { - return $this->apiCall->put($this->endpoint_path($ruleName), $params); - } - - public function retrieve() - { - return $this->apiCall->get($this->endpoint_path(), []); - } - - private function endpoint_path($operation = null) - { - return self::RESOURCE_PATH . ($operation === null ? '' : "/$operation"); - } -} diff --git a/src/ApiCall.php b/src/ApiCall.php deleted file mode 100644 index 435ff107..00000000 --- a/src/ApiCall.php +++ /dev/null @@ -1,369 +0,0 @@ -<?php - -namespace Typesense; - -use Exception; -use Http\Client\Exception as HttpClientException; -use Http\Client\Exception\HttpException; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\StreamInterface; -use Psr\Log\LoggerInterface; -use Typesense\Exceptions\HTTPStatus0Error; -use Typesense\Exceptions\ObjectAlreadyExists; -use Typesense\Exceptions\ObjectNotFound; -use Typesense\Exceptions\ObjectUnprocessable; -use Typesense\Exceptions\RequestMalformed; -use Typesense\Exceptions\RequestUnauthorized; -use Typesense\Exceptions\ServerError; -use Typesense\Exceptions\ServiceUnavailable; -use Typesense\Exceptions\TypesenseClientError; -use Typesense\Lib\Configuration; -use Typesense\Lib\Node; - -/** - * Class ApiCall - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ApiCall -{ - - private const API_KEY_HEADER_NAME = 'X-TYPESENSE-API-KEY'; - - /** - * @var ClientInterface - */ - private ClientInterface $client; - - /** - * @var Configuration - */ - private Configuration $config; - - /** - * @var array|Node[] - */ - private static array $nodes; - - /** - * @var Node|null - */ - private static ?Node $nearestNode; - - /** - * @var int - */ - private int $nodeIndex; - - /** - * @var LoggerInterface - */ - public LoggerInterface $logger; - - /** - * ApiCall constructor. - * - * @param Configuration $config - */ - public function __construct(Configuration $config) - { - $this->config = $config; - $this->logger = $config->getLogger(); - $this->client = $config->getClient(); - static::$nodes = $this->config->getNodes(); - static::$nearestNode = $this->config->getNearestNode(); - $this->nodeIndex = 0; - $this->initializeNodes(); - } - - /** - * Initialize Nodes - */ - private function initializeNodes(): void - { - if (static::$nearestNode !== null) { - $this->setNodeHealthCheck(static::$nearestNode, true); - } - - foreach (static::$nodes as &$node) { - $this->setNodeHealthCheck($node, true); - } - } - - /** - * @param string $endPoint - * @param array $params - * @param bool $asJson - * - * @return string|array - * @throws TypesenseClientError - * @throws Exception|HttpClientException - */ - public function get(string $endPoint, array $params, bool $asJson = true) - { - return $this->makeRequest('get', $endPoint, $asJson, [ - 'query' => $params ?? [], - ]); - } - - /** - * @param string $endPoint - * @param mixed $body - * - * @param bool $asJson - * @param array $queryParameters - * - * @return array|string - * @throws TypesenseClientError - * @throws HttpClientException - */ - public function post(string $endPoint, $body, bool $asJson = true, array $queryParameters = []) - { - return $this->makeRequest('post', $endPoint, $asJson, [ - 'data' => $body ?? [], - 'query' => $queryParameters ?? [] - ]); - } - - /** - * @param string $endPoint - * @param array $body - * - * @param bool $asJson - * @param array $queryParameters - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function put(string $endPoint, array $body, bool $asJson = true, array $queryParameters = []): array - { - return $this->makeRequest('put', $endPoint, $asJson, [ - 'data' => $body ?? [], - 'query' => $queryParameters ?? [] - ]); - } - - /** - * @param string $endPoint - * @param array $body - * - * @param bool $asJson - * @param array $queryParameters - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function patch(string $endPoint, array $body, bool $asJson = true, array $queryParameters = []): array - { - return $this->makeRequest('patch', $endPoint, $asJson, [ - 'data' => $body ?? [], - 'query' => $queryParameters ?? [] - ]); - } - - /** - * @param string $endPoint - * - * @param bool $asJson - * @param array $queryParameters - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(string $endPoint, bool $asJson = true, array $queryParameters = []): array - { - return $this->makeRequest('delete', $endPoint, $asJson, [ - 'query' => $queryParameters ?? [] - ]); - } - - /** - * Makes the actual http request, along with retries - * - * @param string $method - * @param string $endPoint - * @param bool $asJson - * @param array $options - * - * @return string|array - * @throws TypesenseClientError|HttpClientException - * @throws Exception - */ - private function makeRequest(string $method, string $endPoint, bool $asJson, array $options) - { - $numRetries = 0; - $lastException = null; - while ($numRetries < $this->config->getNumRetries() + 1) { - $numRetries++; - $node = $this->getNode(); - - try { - $url = $node->url() . $endPoint; - $reqOp = $this->getRequestOptions(); - if (isset($options['data'])) { - if (is_string($options['data']) || $options['data'] instanceof StreamInterface) { - $reqOp['body'] = $options['data']; - } else { - $reqOp['body'] = \json_encode($options['data']); - } - } - - if (isset($options['query'])) { - foreach ($options['query'] as $key => $value) : - if (is_bool($value)) { - $options['query'][$key] = ($value) ? 'true' : 'false'; - } - endforeach; - $reqOp['query'] = http_build_query($options['query']); - } - - $response = $this->client->send( - \strtoupper($method), - $url . '?' . ($reqOp['query'] ?? ''), - $reqOp['headers'] ?? [], - $reqOp['body'] ?? null - ); - - $statusCode = $response->getStatusCode(); - if (0 < $statusCode && $statusCode < 500) { - $this->setNodeHealthCheck($node, true); - } - - if (!(200 <= $statusCode && $statusCode < 300)) { - $errorMessage = json_decode($response->getBody() - ->getContents(), true, 512, JSON_THROW_ON_ERROR)['message'] ?? 'API error.'; - throw $this->getException($statusCode) - ->setMessage($errorMessage); - } - - return $asJson ? json_decode($response->getBody() - ->getContents(), true, 512, JSON_THROW_ON_ERROR) : $response->getBody() - ->getContents(); - } catch (HttpException $exception) { - if ( - $exception->getResponse() - ->getStatusCode() === 408 - ) { - continue; - } - $this->setNodeHealthCheck($node, false); - throw $this->getException($exception->getResponse() - ->getStatusCode()) - ->setMessage($exception->getMessage()); - } catch (TypesenseClientError | HttpClientException $exception) { - $this->setNodeHealthCheck($node, false); - throw $exception; - } catch (Exception $exception) { - $this->setNodeHealthCheck($node, false); - $lastException = $exception; - sleep($this->config->getRetryIntervalSeconds()); - } - } - - if ($lastException) { - throw $lastException; - } - } - - /** - * @return array - */ - private function getRequestOptions(): array - { - return [ - 'headers' => [ - static::API_KEY_HEADER_NAME => $this->config->getApiKey(), - ] - ]; - } - - /** - * @param Node $node - * - * @return bool - */ - private function nodeDueForHealthCheck(Node $node): bool - { - $currentTimestamp = time(); - return ($currentTimestamp - $node->getLastAccessTs()) > $this->config->getHealthCheckIntervalSeconds(); - } - - /** - * @param Node $node - * @param bool $isHealthy - */ - public function setNodeHealthCheck(Node $node, bool $isHealthy): void - { - $node->setHealthy($isHealthy); - $node->setLastAccessTs(time()); - } - - /** - * Returns a healthy host from the pool in a round-robin fashion - * Might return an unhealthy host periodically to check for recovery. - * - * @return Node - */ - public function getNode(): Lib\Node - { - if (static::$nearestNode !== null) { - if (static::$nearestNode->isHealthy() || $this->nodeDueForHealthCheck(static::$nearestNode)) { - return static::$nearestNode; - } - } - $i = 0; - while ($i < count(static::$nodes)) { - $i++; - $node = static::$nodes[$this->nodeIndex]; - $this->nodeIndex = ($this->nodeIndex + 1) % count(static::$nodes); - if ($node->isHealthy() || $this->nodeDueForHealthCheck($node)) { - return $node; - } - } - - /** - * None of the nodes are marked healthy, but some of them could have become healthy since last health check. - * So we will just return the next node. - */ - return static::$nodes[$this->nodeIndex]; - } - - /** - * @param int $httpCode - * - * @return TypesenseClientError - */ - public function getException(int $httpCode): TypesenseClientError - { - switch ($httpCode) { - case 0: - return new HTTPStatus0Error(); - case 400: - return new RequestMalformed(); - case 401: - return new RequestUnauthorized(); - case 404: - return new ObjectNotFound(); - case 409: - return new ObjectAlreadyExists(); - case 422: - return new ObjectUnprocessable(); - case 500: - return new ServerError(); - case 503: - return new ServiceUnavailable(); - default: - return new TypesenseClientError(); - } - } - - /** - * @return LoggerInterface - */ - public function getLogger() - { - return $this->logger; - } -} diff --git a/src/Client.php b/src/Client.php deleted file mode 100644 index d64be61f..00000000 --- a/src/Client.php +++ /dev/null @@ -1,180 +0,0 @@ -<?php - -namespace Typesense; - -use Typesense\Exceptions\ConfigError; -use Typesense\Lib\Configuration; - -/** - * Class Client - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Client -{ - /** - * @var Configuration - */ - private Configuration $config; - - /** - * @var Collections - */ - public Collections $collections; - - /** - * @var Aliases - */ - public Aliases $aliases; - - /** - * @var Keys - */ - public Keys $keys; - - /** - * @var Debug - */ - public Debug $debug; - - /** - * @var Metrics - */ - public Metrics $metrics; - - /** - * @var Health - */ - public Health $health; - - /** - * @var Operations - */ - public Operations $operations; - - /** - * @var MultiSearch - */ - public MultiSearch $multiSearch; - - /** - * @var Presets - */ - public Presets $presets; - - /** - * @var Analytics - */ - public Analytics $analytics; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Client constructor. - * - * @param array $config - * - * @throws ConfigError - */ - public function __construct(array $config) - { - $this->config = new Configuration($config); - $this->apiCall = new ApiCall($this->config); - - $this->collections = new Collections($this->apiCall); - $this->aliases = new Aliases($this->apiCall); - $this->keys = new Keys($this->apiCall); - $this->debug = new Debug($this->apiCall); - $this->metrics = new Metrics($this->apiCall); - $this->health = new Health($this->apiCall); - $this->operations = new Operations($this->apiCall); - $this->multiSearch = new MultiSearch($this->apiCall); - $this->presets = new Presets($this->apiCall); - $this->analytics = new Analytics($this->apiCall); - } - - /** - * @return Collections - */ - public function getCollections(): Collections - { - return $this->collections; - } - - /** - * @return Aliases - */ - public function getAliases(): Aliases - { - return $this->aliases; - } - - /** - * @return Keys - */ - public function getKeys(): Keys - { - return $this->keys; - } - - /** - * @return Debug - */ - public function getDebug(): Debug - { - return $this->debug; - } - - /** - * @return Metrics - */ - public function getMetrics(): Metrics - { - return $this->metrics; - } - - /** - * @return Health - */ - public function getHealth(): Health - { - return $this->health; - } - - /** - * @return Operations - */ - public function getOperations(): Operations - { - return $this->operations; - } - - /** - * @return MultiSearch - */ - public function getMultiSearch(): MultiSearch - { - return $this->multiSearch; - } - - /** - * @return Presets - */ - public function getPresets(): Presets - { - return $this->presets; - } - - /** - * @return Analytics - */ - public function getAnalytics(): Analytics - { - return $this->analytics; - } -} diff --git a/src/Collection.php b/src/Collection.php deleted file mode 100644 index 91748e32..00000000 --- a/src/Collection.php +++ /dev/null @@ -1,118 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Collection - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Collection -{ - - /** - * @var string - */ - private string $name; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var Documents - */ - public Documents $documents; - - /** - * @var Overrides - */ - public Overrides $overrides; - - /** - * @var Synonyms - */ - public Synonyms $synonyms; - - /** - * Collection constructor. - * - * @param string $name - * @param ApiCall $apiCall - */ - public function __construct(string $name, ApiCall $apiCall) - { - $this->name = $name; - $this->apiCall = $apiCall; - $this->documents = new Documents($name, $this->apiCall); - $this->overrides = new Overrides($name, $this->apiCall); - $this->synonyms = new Synonyms($name, $this->apiCall); - } - - /** - * @return string - */ - public function endPointPath(): string - { - return sprintf('%s/%s', Collections::RESOURCE_PATH, $this->name); - } - - /** - * @return Documents - */ - public function getDocuments(): Documents - { - return $this->documents; - } - - /** - * @return Overrides - */ - public function getOverrides(): Overrides - { - return $this->overrides; - } - - /** - * @return Synonyms - */ - public function getSynonyms(): Synonyms - { - return $this->synonyms; - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @param array $schema - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function update(array $schema): array - { - return $this->apiCall->patch($this->endPointPath(), $schema); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endPointPath()); - } -} diff --git a/src/Collections.php b/src/Collections.php deleted file mode 100644 index 9385aa38..00000000 --- a/src/Collections.php +++ /dev/null @@ -1,112 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Collections - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Collections implements \ArrayAccess -{ - - public const RESOURCE_PATH = '/collections'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var array - */ - private array $collections = []; - - /** - * Collections constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param $collectionName - * - * @return mixed - */ - public function __get($collectionName) - { - if (isset($this->{$collectionName})) { - return $this->{$collectionName}; - } - if (!isset($this->collections[$collectionName])) { - $this->collections[$collectionName] = new Collection($collectionName, $this->apiCall); - } - - return $this->collections[$collectionName]; - } - - /** - * @param array $schema - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function create(array $schema): array - { - return $this->apiCall->post(static::RESOURCE_PATH, $schema); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(static::RESOURCE_PATH, []); - } - - /** - * @inheritDoc - */ - public function offsetExists($offset): bool - { - return isset($this->collections[$offset]); - } - - /** - * @inheritDoc - */ - public function offsetGet($offset): Collection - { - if (!isset($this->collections[$offset])) { - $this->collections[$offset] = new Collection($offset, $this->apiCall); - } - - return $this->collections[$offset]; - } - - /** - * @inheritDoc - */ - public function offsetSet($offset, $value): void - { - $this->collections[$offset] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset($offset): void - { - unset($this->collections[$offset]); - } -} diff --git a/src/Debug.php b/src/Debug.php deleted file mode 100644 index c6987387..00000000 --- a/src/Debug.php +++ /dev/null @@ -1,41 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Debug - * - * @package \Typesense - * @date 10/12/20 - */ -class Debug -{ - public const RESOURCE_PATH = '/debug'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(Debug::RESOURCE_PATH, []); - } -} diff --git a/src/Document.php b/src/Document.php deleted file mode 100644 index 617bf9ee..00000000 --- a/src/Document.php +++ /dev/null @@ -1,90 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Document - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Document -{ - - /** - * @var string - */ - private string $collectionName; - - /** - * @var string - */ - private string $documentId; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Document constructor. - * - * @param string $collectionName - * @param string $documentId - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, string $documentId, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->documentId = $documentId; - $this->apiCall = $apiCall; - } - - /** - * @return string - */ - private function endpointPath(): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - Documents::RESOURCE_PATH, - $this->documentId - ); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endpointPath(), []); - } - - /** - * @param array $partialDocument - * @param array $options - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function update(array $partialDocument, array $options = []): array - { - return $this->apiCall->patch($this->endpointPath(), $partialDocument, true, $options); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endpointPath()); - } -} diff --git a/src/Documents.php b/src/Documents.php deleted file mode 100644 index 39c3bc6e..00000000 --- a/src/Documents.php +++ /dev/null @@ -1,234 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Documents - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Documents implements \ArrayAccess -{ - - public const RESOURCE_PATH = 'documents'; - - /** - * @var string - */ - private string $collectionName; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var array - */ - private array $documents = []; - - /** - * Documents constructor. - * - * @param string $collectionName - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->apiCall = $apiCall; - } - - /** - * @param string $action - * - * @return string - */ - private function endPointPath(string $action = ''): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - static::RESOURCE_PATH, - $action - ); - } - - /** - * @param array $document - * @param array $options - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function create(array $document, array $options = []): array - { - return $this->apiCall->post($this->endPointPath(''), $document, true, $options); - } - - /** - * @param array $document - * @param array $options - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function upsert(array $document, array $options = []): array - { - return $this->apiCall->post( - $this->endPointPath(''), - $document, - true, - array_merge($options, ['action' => 'upsert']) - ); - } - - /** - * @param array $document - * @param array $options - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function update(array $document, array $options = []): array - { - return $this->apiCall->post( - $this->endPointPath(''), - $document, - true, - array_merge($options, ['action' => 'update']) - ); - } - - /** - * @param array $documents - * @param array $options - * - * @return array - * @throws TypesenseClientError|HttpClientException|\JsonException - */ - public function createMany(array $documents, array $options = []): array - { - $this->apiCall->getLogger()->warning( - "createMany is deprecated and will be removed in a future version. " . - "Use import instead, which now takes both an array of documents or a JSONL string of documents" - ); - return $this->import($documents, $options); - } - - /** - * @param string|array $documents - * @param array $options - * - * @return string|array - * @throws TypesenseClientError - * @throws \JsonException|HttpClientException - */ - public function import($documents, array $options = []) - { - if (is_array($documents)) { - $documentsInJSONLFormat = implode( - "\n", - array_map( - static fn(array $document) => json_encode($document, JSON_THROW_ON_ERROR), - $documents - ) - ); - } else { - $documentsInJSONLFormat = $documents; - } - $resultsInJSONLFormat = $this->apiCall->post( - $this->endPointPath('import'), - $documentsInJSONLFormat, - false, - $options - ); - - if (is_array($documents)) { - return array_map(static function ($item) { - return json_decode($item, true, 512, JSON_THROW_ON_ERROR); - }, explode("\n", $resultsInJSONLFormat)); - } else { - return $resultsInJSONLFormat; - } - } - - /** - * @param array $queryParams - * - * @return string - * @throws TypesenseClientError|HttpClientException - */ - public function export(array $queryParams = []): string - { - return $this->apiCall->get($this->endPointPath('export'), $queryParams, false); - } - - /** - * @param array $queryParams - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(array $queryParams = []): array - { - return $this->apiCall->delete($this->endPointPath(), true, $queryParams); - } - - /** - * @param array $searchParams - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function search(array $searchParams): array - { - return $this->apiCall->get($this->endPointPath('search'), $searchParams); - } - - /** - * @param mixed $documentId - * - * @return bool - */ - public function offsetExists($documentId): bool - { - return isset($this->documents[$documentId]); - } - - /** - * @inheritDoc - */ - public function offsetGet($documentId): Document - { - if (!isset($this->documents[$documentId])) { - $this->documents[$documentId] = new Document($this->collectionName, $documentId, $this->apiCall); - } - - return $this->documents[$documentId]; - } - - /** - * @inheritDoc - */ - public function offsetUnset($documentId): void - { - if (isset($this->documents[$documentId])) { - unset($this->documents[$documentId]); - } - } - - /** - * @inheritDoc - */ - public function offsetSet($offset, $value): void - { - $this->documents[$offset] = $value; - } -} diff --git a/src/Exceptions/Client/ClientErrorException.php b/src/Exceptions/Client/ClientErrorException.php new file mode 100644 index 00000000..37a9e92e --- /dev/null +++ b/src/Exceptions/Client/ClientErrorException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +use Typesense\Exceptions\TypesenseException; + +abstract class ClientErrorException extends TypesenseException +{ + // +} diff --git a/src/Exceptions/Client/InvalidPayloadException.php b/src/Exceptions/Client/InvalidPayloadException.php new file mode 100644 index 00000000..beb55b72 --- /dev/null +++ b/src/Exceptions/Client/InvalidPayloadException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +class InvalidPayloadException extends ClientErrorException +{ + // +} diff --git a/src/Exceptions/Client/ResourceAlreadyExistsException.php b/src/Exceptions/Client/ResourceAlreadyExistsException.php new file mode 100644 index 00000000..0117838c --- /dev/null +++ b/src/Exceptions/Client/ResourceAlreadyExistsException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +class ResourceAlreadyExistsException extends ClientErrorException +{ + // +} diff --git a/src/Exceptions/Client/ResourceNotFoundException.php b/src/Exceptions/Client/ResourceNotFoundException.php new file mode 100644 index 00000000..fe4770fd --- /dev/null +++ b/src/Exceptions/Client/ResourceNotFoundException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +class ResourceNotFoundException extends ClientErrorException +{ + // +} diff --git a/src/Exceptions/Client/UnauthorizedException.php b/src/Exceptions/Client/UnauthorizedException.php new file mode 100644 index 00000000..78d6e4f4 --- /dev/null +++ b/src/Exceptions/Client/UnauthorizedException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +class UnauthorizedException extends ClientErrorException +{ + // +} diff --git a/src/Exceptions/Client/UnprocessableEntityException.php b/src/Exceptions/Client/UnprocessableEntityException.php new file mode 100644 index 00000000..17c86a59 --- /dev/null +++ b/src/Exceptions/Client/UnprocessableEntityException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Client; + +class UnprocessableEntityException extends ClientErrorException +{ + // +} diff --git a/src/Exceptions/ConfigError.php b/src/Exceptions/ConfigError.php deleted file mode 100644 index b4fcc7cb..00000000 --- a/src/Exceptions/ConfigError.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ConfigError - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ConfigError extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/HTTPStatus0Error.php b/src/Exceptions/HTTPStatus0Error.php deleted file mode 100644 index e2f2752c..00000000 --- a/src/Exceptions/HTTPStatus0Error.php +++ /dev/null @@ -1,14 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class HTTPStatus0Error - * - * @date 9/3/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class HTTPStatus0Error extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/MalformedResponsePayloadException.php b/src/Exceptions/MalformedResponsePayloadException.php new file mode 100644 index 00000000..4465b787 --- /dev/null +++ b/src/Exceptions/MalformedResponsePayloadException.php @@ -0,0 +1,17 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions; + +class MalformedResponsePayloadException extends TypesenseException +{ + /** + * Constructor. + */ + public function __construct( + public string $context, + ) { + parent::__construct(); + } +} diff --git a/src/Exceptions/ObjectAlreadyExists.php b/src/Exceptions/ObjectAlreadyExists.php deleted file mode 100644 index 956cffd1..00000000 --- a/src/Exceptions/ObjectAlreadyExists.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ObjectAlreadyExists - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ObjectAlreadyExists extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/ObjectNotFound.php b/src/Exceptions/ObjectNotFound.php deleted file mode 100644 index 7cae4b82..00000000 --- a/src/Exceptions/ObjectNotFound.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ObjectNotFound - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ObjectNotFound extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/ObjectUnprocessable.php b/src/Exceptions/ObjectUnprocessable.php deleted file mode 100644 index 89a2f922..00000000 --- a/src/Exceptions/ObjectUnprocessable.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ObjectUnprocessable - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ObjectUnprocessable extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/RequestMalformed.php b/src/Exceptions/RequestMalformed.php deleted file mode 100644 index 2a7c42b7..00000000 --- a/src/Exceptions/RequestMalformed.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class RequestMalformed - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class RequestMalformed extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/RequestUnauthorized.php b/src/Exceptions/RequestUnauthorized.php deleted file mode 100644 index 47ec3533..00000000 --- a/src/Exceptions/RequestUnauthorized.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class RequestUnauthorized - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class RequestUnauthorized extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/Server/ServerErrorException.php b/src/Exceptions/Server/ServerErrorException.php new file mode 100644 index 00000000..bc3b0ccd --- /dev/null +++ b/src/Exceptions/Server/ServerErrorException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Server; + +use Typesense\Exceptions\TypesenseException; + +abstract class ServerErrorException extends TypesenseException +{ + // +} diff --git a/src/Exceptions/Server/ServiceUnavailableException.php b/src/Exceptions/Server/ServiceUnavailableException.php new file mode 100644 index 00000000..99bf778c --- /dev/null +++ b/src/Exceptions/Server/ServiceUnavailableException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions\Server; + +class ServiceUnavailableException extends ServerErrorException +{ + // +} diff --git a/src/Exceptions/ServerError.php b/src/Exceptions/ServerError.php deleted file mode 100644 index e13d18d0..00000000 --- a/src/Exceptions/ServerError.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ServerError - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ServerError extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/ServiceUnavailable.php b/src/Exceptions/ServiceUnavailable.php deleted file mode 100644 index 1b32bcb1..00000000 --- a/src/Exceptions/ServiceUnavailable.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class ServiceUnavailable - * - * @package \Typesense\Exceptions - * @date 4/8/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class ServiceUnavailable extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/Timeout.php b/src/Exceptions/Timeout.php deleted file mode 100644 index 359eb338..00000000 --- a/src/Exceptions/Timeout.php +++ /dev/null @@ -1,15 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -/** - * Class Timeout - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Timeout extends TypesenseClientError -{ - -} diff --git a/src/Exceptions/TypesenseClientError.php b/src/Exceptions/TypesenseClientError.php deleted file mode 100644 index 2f20c2a8..00000000 --- a/src/Exceptions/TypesenseClientError.php +++ /dev/null @@ -1,22 +0,0 @@ -<?php - -namespace Typesense\Exceptions; - -use Exception; - -/** - * Class TypesenseClientError - * - * @package \Typesense\Exceptions - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class TypesenseClientError extends Exception -{ - - public function setMessage(string $message): TypesenseClientError - { - $this->message = $message; - return $this; - } -} diff --git a/src/Exceptions/TypesenseException.php b/src/Exceptions/TypesenseException.php new file mode 100644 index 00000000..13959fcd --- /dev/null +++ b/src/Exceptions/TypesenseException.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions; + +use Exception; + +abstract class TypesenseException extends Exception +{ + // +} diff --git a/src/Exceptions/UnknownHttpException.php b/src/Exceptions/UnknownHttpException.php new file mode 100644 index 00000000..ac2b9877 --- /dev/null +++ b/src/Exceptions/UnknownHttpException.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Exceptions; + +class UnknownHttpException extends TypesenseException +{ + // +} diff --git a/src/Health.php b/src/Health.php deleted file mode 100644 index f0db09ed..00000000 --- a/src/Health.php +++ /dev/null @@ -1,41 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Health - * - * @package \Typesense - * @date 10/12/20 - */ -class Health -{ - public const RESOURCE_PATH = '/health'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(Health::RESOURCE_PATH, []); - } -} diff --git a/src/Http.php b/src/Http.php new file mode 100644 index 00000000..a90530c0 --- /dev/null +++ b/src/Http.php @@ -0,0 +1,56 @@ +<?php + +declare(strict_types=1); + +namespace Typesense; + +use Http\Discovery\Psr18Client; +use Psr\Http\Message\ResponseInterface; + +/** + * @phpstan-import-type TypesenseConfiguration from Typesense + */ +class Http +{ + public Psr18Client $client; + + /** + * @param TypesenseConfiguration $config + */ + public function __construct( + public array $config, + ) { + $this->client = new Psr18Client($config['http'] ?? null); + } + + /** + * @param 'GET'|'HEAD'|'POST'|'PATCH'|'PUT'|'DELETE' $method + */ + public function request(string $method, string $path, string $body = ''): ResponseInterface + { + $request = $this->client + ->createRequest($method, $this->uri($path)) + ->withHeader('Content-Type', 'application/json') + ->withHeader('X-TYPESENSE-API-KEY', $this->config['apiKey']); + + if (! empty($body)) { + $request = $request->withBody( + $this->client->createStream($body), + ); + } + + return $this->client->sendRequest($request); + } + + /** + * Form a complete request URL. + */ + public function uri(string $path): string + { + return sprintf( + '%s/%s', + rtrim($this->config['url'], '/'), + ltrim($path, '/'), + ); + } +} diff --git a/src/Key.php b/src/Key.php deleted file mode 100644 index af7a9b1f..00000000 --- a/src/Key.php +++ /dev/null @@ -1,65 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Key - * - * @package \Typesense - * @date 6/1/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Key -{ - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var string - */ - private string $keyId; - - /** - * Key constructor. - * - * @param string $keyId - * @param ApiCall $apiCall - */ - public function __construct(string $keyId, ApiCall $apiCall) - { - $this->keyId = $keyId; - $this->apiCall = $apiCall; - } - - /** - * @return string - */ - private function endpointPath(): string - { - return sprintf('%s/%s', Keys::RESOURCE_PATH, $this->keyId); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endpointPath(), []); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endpointPath()); - } -} diff --git a/src/Keys.php b/src/Keys.php deleted file mode 100644 index 740d9098..00000000 --- a/src/Keys.php +++ /dev/null @@ -1,129 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Keys - * - * @package \Typesense - * @date 6/1/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Keys implements \ArrayAccess -{ - - public const RESOURCE_PATH = '/keys'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var array - */ - private array $keys = []; - - /** - * Keys constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param array $schema - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function create(array $schema): array - { - return $this->apiCall->post(static::RESOURCE_PATH, $schema); - } - - /** - * @param string $searchKey - * @param array $parameters - * - * @return string - * @throws \JsonException - */ - public function generateScopedSearchKey( - string $searchKey, - array $parameters - ): string { - $paramStr = json_encode($parameters, JSON_THROW_ON_ERROR); - $digest = base64_encode( - hash_hmac( - 'sha256', - mb_convert_encoding($paramStr, 'UTF-8', 'ISO-8859-1'), - mb_convert_encoding($searchKey, 'UTF-8', 'ISO-8859-1'), - true) - ); - $keyPrefix = substr($searchKey, 0, 4); - $rawScopedKey = sprintf( - '%s%s%s', - mb_convert_encoding($digest, 'ISO-8859-1', 'UTF-8'), - $keyPrefix, - $paramStr - ); - return base64_encode(mb_convert_encoding($rawScopedKey, 'UTF-8', 'ISO-8859-1')); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(static::RESOURCE_PATH, []); - } - - /** - * @param mixed $offset - * - * @return bool - */ - public function offsetExists($offset): bool - { - return isset($this->keys[$offset]); - } - - /** - * @param mixed $offset - * - * @return \Typesense\Key - */ - public function offsetGet($offset): Key - { - if (!isset($this->keys[$offset])) { - $this->keys[$offset] = new Key($offset, $this->apiCall); - } - - return $this->keys[$offset]; - } - - /** - * @param mixed $offset - * @param mixed $value - */ - public function offsetSet($offset, $value): void - { - $this->keys[$offset] = $value; - } - - /** - * @param mixed $offset - */ - public function offsetUnset($offset): void - { - unset($this->keys[$offset]); - } -} diff --git a/src/Lib/Configuration.php b/src/Lib/Configuration.php deleted file mode 100644 index 54f0e9ea..00000000 --- a/src/Lib/Configuration.php +++ /dev/null @@ -1,226 +0,0 @@ -<?php - -namespace Typesense\Lib; - -use Http\Client\Common\HttpMethodsClient; -use Http\Discovery\Psr17FactoryDiscovery; -use Http\Discovery\Psr18ClientDiscovery; -use Monolog\Handler\StreamHandler; -use Monolog\Logger; -use Psr\Http\Client\ClientInterface; -use Psr\Log\LoggerInterface; -use Typesense\Exceptions\ConfigError; - -/** - * Class Configuration - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Configuration -{ - - /** - * @var Node[] - */ - private array $nodes; - - /** - * @var Node|null - */ - private ?Node $nearestNode; - - /** - * @var string - */ - private string $apiKey; - - /** - * @var float - */ - private float $numRetries; - - /** - * @var float - */ - private float $retryIntervalSeconds; - - /** - * @var int - */ - private int $healthCheckIntervalSeconds; - - /** - * @var LoggerInterface - */ - private LoggerInterface $logger; - - /** - * @var null|ClientInterface - */ - private ?ClientInterface $client = null; - - /** - * @var int - */ - private int $logLevel; - - /** - * Configuration constructor. - * - * @param array $config - * - * @throws ConfigError - */ - public function __construct(array $config) - { - $this->validateConfigArray($config); - - $nodes = $config['nodes'] ?? []; - - foreach ($nodes as $node) { - $this->nodes[] = new Node($node['host'], $node['port'], $node['path'] ?? '', $node['protocol']); - } - - $nearestNode = $config['nearest_node'] ?? null; - $this->nearestNode = null; - if (null !== $nearestNode) { - $this->nearestNode = - new Node( - $nearestNode['host'], - $nearestNode['port'], - $nearestNode['path'] ?? '', - $nearestNode['protocol'] - ); - } - - $this->apiKey = $config['api_key'] ?? ''; - $this->healthCheckIntervalSeconds = (int)($config['healthcheck_interval_seconds'] ?? 60); - $this->numRetries = (float)($config['num_retries'] ?? 3); - $this->retryIntervalSeconds = (float)($config['retry_interval_seconds'] ?? 1.0); - - $this->logLevel = $config['log_level'] ?? Logger::WARNING; - $this->logger = new Logger('typesense'); - $this->logger->pushHandler(new StreamHandler('php://stdout', $this->logLevel)); - - if (true === \array_key_exists('client', $config) && $config['client'] instanceof ClientInterface) { - $this->client = $config['client']; - } - } - - /** - * @param array $config - * - * @throws ConfigError - */ - private function validateConfigArray(array $config): void - { - $nodes = $config['nodes'] ?? false; - if (!$nodes) { - throw new ConfigError('`nodes` is not defined.'); - } - - $apiKey = $config['api_key'] ?? false; - if (!$apiKey) { - throw new ConfigError('`api_key` is not defined.'); - } - - foreach ($nodes as $node) { - if (!$this->validateNodeFields($node)) { - throw new ConfigError( - '`node` entry be a dictionary with the following required keys: host, port, protocol, api_key' - ); - } - } - $nearestNode = $config['nearest_node'] ?? []; - if (!empty($nearestNode) && !$this->validateNodeFields($nearestNode)) { - throw new ConfigError( - '`nearest_node` entry be a dictionary with the following required keys: host, port, protocol, api_key' - ); - } - } - - /** - * @param array $node - * - * @return bool - */ - public function validateNodeFields(array $node): bool - { - $keys = [ - 'host', - 'port', - 'protocol', - ]; - return !array_diff_key(array_flip($keys), $node); - } - - /** - * @return Node[] - */ - public function getNodes(): array - { - return $this->nodes; - } - - /** - * @return Node - */ - public function getNearestNode(): ?Node - { - return $this->nearestNode; - } - - /** - * @return mixed|string - */ - public function getApiKey() - { - return $this->apiKey; - } - - /** - * @return float - */ - public function getNumRetries(): float - { - return $this->numRetries; - } - - /** - * @return float - */ - public function getRetryIntervalSeconds(): float - { - return $this->retryIntervalSeconds; - } - - /** - * @return float|mixed - */ - public function getHealthCheckIntervalSeconds() - { - return $this->healthCheckIntervalSeconds; - } - - /** - * @return LoggerInterface - */ - public function getLogger(): LoggerInterface - { - return $this->logger; - } - - /** - * @return ClientInterface - */ - public function getClient(): ClientInterface - { - return new HttpMethodsClient( - $this->client ?? Psr18ClientDiscovery::find(), - Psr17FactoryDiscovery::findRequestFactory(), - Psr17FactoryDiscovery::findStreamFactory(), - ); - } -} diff --git a/src/Lib/Node.php b/src/Lib/Node.php deleted file mode 100644 index 3500baae..00000000 --- a/src/Lib/Node.php +++ /dev/null @@ -1,105 +0,0 @@ -<?php - -namespace Typesense\Lib; - -/** - * Class Node - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Node -{ - - /** - * @var string - */ - private string $host; - - /** - * @var string - */ - private string $port; - - /** - * @var string - */ - private string $path; - - /** - * @var string - */ - private string $protocol; - - /** - * @var bool - */ - private bool $healthy = false; - - /** - * @var int - */ - private int $lastAccessTs; - - /** - * Node constructor. - * - * @param string $host - * @param string $port - * @param string $path - * @param string $protocol - */ - public function __construct( - string $host, - string $port, - string $path, - string $protocol - ) { - $this->host = $host; - $this->port = $port; - $this->path = $path; - $this->protocol = $protocol; - $this->lastAccessTs = time(); - } - - /** - * @return string - */ - public function url(): string - { - return sprintf('%s://%s:%s%s', $this->protocol, $this->host, $this->port, $this->path); - } - - /** - * @return bool - */ - public function isHealthy(): bool - { - return $this->healthy; - } - - /** - * @param bool $healthy - */ - public function setHealthy(bool $healthy): void - { - $this->healthy = $healthy; - } - - /** - * @return int - */ - public function getLastAccessTs(): int - { - return $this->lastAccessTs; - } - - /** - * @param int $lastAccessTs - */ - public function setLastAccessTs(int $lastAccessTs): void - { - $this->lastAccessTs = $lastAccessTs; - } -} diff --git a/src/Metrics.php b/src/Metrics.php deleted file mode 100644 index fb3c8b56..00000000 --- a/src/Metrics.php +++ /dev/null @@ -1,41 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Metrics - * - * @package \Typesense - * @date 10/12/20 - */ -class Metrics -{ - public const RESOURCE_PATH = '/metrics.json'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get(Metrics::RESOURCE_PATH, []); - } -} diff --git a/src/MultiSearch.php b/src/MultiSearch.php deleted file mode 100644 index d24f9e9d..00000000 --- a/src/MultiSearch.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class MultiSearch - * - * @package \Typesense - */ -class MultiSearch -{ - public const RESOURCE_PATH = '/multi_search'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param string $searches - * @param array $queryParameters - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function perform(array $searches, array $queryParameters = []): array - { - return $this->apiCall->post( - sprintf('%s', static::RESOURCE_PATH), - $searches, - true, - $queryParameters - ); - } -} diff --git a/src/Objects/Alias.php b/src/Objects/Alias.php new file mode 100644 index 00000000..191485e2 --- /dev/null +++ b/src/Objects/Alias.php @@ -0,0 +1,12 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +class Alias extends TypesenseObject +{ + public string $name; + + public string $collection_name; +} diff --git a/src/Objects/Analytic.php b/src/Objects/Analytic.php new file mode 100644 index 00000000..71e7606f --- /dev/null +++ b/src/Objects/Analytic.php @@ -0,0 +1,27 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +/** + * @phpstan-type RulePayload array{ + * source: array{ + * collections: array<int, string>, + * }, + * destination: array{ + * collection: string, + * }, + * limit: int, + * } + */ +class Analytic extends TypesenseObject +{ + public string $name; + + public string $type; + + public stdClass $params; +} diff --git a/src/Objects/Collection.php b/src/Objects/Collection.php new file mode 100644 index 00000000..16ed65e4 --- /dev/null +++ b/src/Objects/Collection.php @@ -0,0 +1,54 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +class Collection extends TypesenseObject +{ + /** + * @var non-negative-int + */ + public int $created_at; + + public string $default_sorting_field; + + public bool $enable_nested_fields; + + /** + * @var array<int, CollectionField> + */ + public array $fields; + + public string $name; + + /** + * @var non-negative-int + */ + public int $num_documents; + + /** + * @var array<int, string> + */ + public array $symbols_to_index; + + /** + * @var array<int, string> + */ + public array $token_separators; + + /** + * {@inheritdoc} + */ + public static function from(stdClass $data): static + { + $data->fields = array_map( + fn (stdClass $data) => CollectionField::from($data), + $data->fields, + ); + + return parent::from($data); + } +} diff --git a/src/Objects/CollectionDroppedField.php b/src/Objects/CollectionDroppedField.php new file mode 100644 index 00000000..077a938a --- /dev/null +++ b/src/Objects/CollectionDroppedField.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +class CollectionDroppedField extends TypesenseObject +{ + public string $name; + + public true $drop; + + public mixed $embed; +} diff --git a/src/Objects/CollectionField.php b/src/Objects/CollectionField.php new file mode 100644 index 00000000..ee797279 --- /dev/null +++ b/src/Objects/CollectionField.php @@ -0,0 +1,62 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +/** + * @phpstan-type TypesenseCollectionFieldType 'string'|'string[]'|'int32'|'int32[]'|'int64'|'int64[]'|'float'|'float[]'|'bool'|'bool[]'|'geopoint'|'geopoint[]'|'object'|'object[]'|'string*'|'auto' + * @phpstan-type TypesenseCollectionFieldLocale 'ja'|'zh'|'ko'|'th'|'el'|'ru'|'sr'|'uk'|'be' + * @phpstan-type TypesenseCollectionField array{ + * embed: mixed, + * facet: bool, + * index: bool, + * infix: bool, + * locale: TypesenseCollectionFieldLocale, + * name: string, + * nested: bool, + * nested_array: int, + * num_dim: int, + * optional: bool, + * reference: bool, + * sort: bool, + * type: TypesenseCollectionFieldType, + * vec_dist: TypesenseCollectionFieldType, + * } + */ +class CollectionField extends TypesenseObject +{ + public mixed $embed; + + public bool $facet; + + public bool $index; + + public bool $infix; + + /** + * @var TypesenseCollectionFieldLocale|'' + */ + public string $locale; + + public string $name; + + public bool $nested; + + public int $nested_array; + + public int $num_dim; + + public bool $optional; + + public string $reference; + + public bool $sort; + + /** + * @var TypesenseCollectionFieldType + */ + public string $type; + + public string $vec_dist; +} diff --git a/src/Objects/Curation.php b/src/Objects/Curation.php new file mode 100644 index 00000000..b9d970e6 --- /dev/null +++ b/src/Objects/Curation.php @@ -0,0 +1,61 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +/** + * @phpstan-type CurationRule array{ + * query?: string, + * match?: 'exact'|'contains', + * filter_by?: string, + * } + * @phpstan-type CurationExclude array{ + * id: string, + * } + * @phpstan-type CurationInclude array{ + * id: string, + * position: int, + * } + */ +class Curation extends TypesenseObject +{ + public string $id; + + /** + * @var stdClass{ + * query?: string, + * match?: 'exact'|'contains', + * filter_by?: string, + * } + */ + public stdClass $rule; + + /** + * @var array<int, CurationExclude> + */ + public array $excludes = []; + + /** + * @var array<int, CurationInclude> + */ + public array $includes = []; + + public ?string $filter_by = null; + + public ?string $sort_by = null; + + public ?string $replace_query = null; + + public bool $remove_matched_tokens = true; + + public bool $filter_curated_hits = false; + + public ?int $effective_from_ts = null; + + public ?int $effective_to_ts = null; + + public bool $stop_processing = true; +} diff --git a/src/Objects/Document.php b/src/Objects/Document.php new file mode 100644 index 00000000..270a141d --- /dev/null +++ b/src/Objects/Document.php @@ -0,0 +1,10 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +abstract class Document extends TypesenseObject +{ + public string $id; +} diff --git a/src/Objects/GenericDocument.php b/src/Objects/GenericDocument.php new file mode 100644 index 00000000..e7d7f5e9 --- /dev/null +++ b/src/Objects/GenericDocument.php @@ -0,0 +1,13 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use AllowDynamicProperties; + +#[AllowDynamicProperties] +class GenericDocument extends Document +{ + // +} diff --git a/src/Objects/ImportedDocument.php b/src/Objects/ImportedDocument.php new file mode 100644 index 00000000..c85d22a4 --- /dev/null +++ b/src/Objects/ImportedDocument.php @@ -0,0 +1,20 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +class ImportedDocument extends TypesenseObject +{ + public bool $success; + + public ?string $id = null; + + public ?string $error = null; + + public ?int $code = null; + + public string|stdClass|null $document = null; +} diff --git a/src/Objects/Key.php b/src/Objects/Key.php new file mode 100644 index 00000000..ac489509 --- /dev/null +++ b/src/Objects/Key.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +/** + * @phpstan-type KeyAction 'collections:create'|'collections:delete'|'collections:get'|'collections:list'|'collections:*'|'documents:search'|'documents:get'|'documents:create'|'documents:upsert'|'documents:update'|'documents:delete'|'documents:import'|'documents:export'|'documents:*'|'aliases:list'|'aliases:get'|'aliases:create'|'aliases:delete'|'aliases:*'|'synonyms:list'|'synonyms:get'|'synonyms:create'|'synonyms:delete'|'synonyms:*'|'overrides:list'|'overrides:get'|'overrides:create'|'overrides:delete'|'overrides:*'|'keys:list'|'keys:get'|'keys:create'|'keys:delete'|'keys:*'|'metrics.json:list'|'stats.json:list'|'debug:list'|'*' + */ +class Key extends TypesenseObject +{ + /** + * @var non-empty-array<int, KeyAction> + */ + public array $actions; + + /** + * @var non-empty-array<int, string> + */ + public array $collections; + + public string $description; + + public int $expires_at; + + public int $id; + + public ?string $value = null; + + public ?string $value_prefix = null; +} diff --git a/src/Objects/Metric.php b/src/Objects/Metric.php new file mode 100644 index 00000000..eb0514a8 --- /dev/null +++ b/src/Objects/Metric.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +class Metric extends TypesenseObject +{ + public string $system_cpu1_active_percentage; + + public ?string $system_cpu2_active_percentage = null; + + public ?string $system_cpu3_active_percentage = null; + + public ?string $system_cpu4_active_percentage = null; + + public ?string $system_cpu5_active_percentage = null; + + public ?string $system_cpu6_active_percentage = null; + + public ?string $system_cpu7_active_percentage = null; + + public ?string $system_cpu8_active_percentage = null; + + public ?string $system_cpu9_active_percentage = null; + + public ?string $system_cpu10_active_percentage = null; + + public ?string $system_cpu11_active_percentage = null; + + public ?string $system_cpu12_active_percentage = null; + + public ?string $system_cpu13_active_percentage = null; + + public ?string $system_cpu14_active_percentage = null; + + public ?string $system_cpu15_active_percentage = null; + + public ?string $system_cpu16_active_percentage = null; + + public ?string $system_cpu17_active_percentage = null; + + public ?string $system_cpu18_active_percentage = null; + + public ?string $system_cpu19_active_percentage = null; + + public ?string $system_cpu20_active_percentage = null; + + public ?string $system_cpu21_active_percentage = null; + + public ?string $system_cpu22_active_percentage = null; + + public ?string $system_cpu23_active_percentage = null; + + public ?string $system_cpu24_active_percentage = null; + + public string $system_cpu_active_percentage; + + public string $system_disk_total_bytes; + + public string $system_disk_used_bytes; + + public string $system_memory_total_bytes; + + public string $system_memory_used_bytes; + + public string $system_network_received_bytes; + + public string $system_network_sent_bytes; + + public string $typesense_memory_active_bytes; + + public string $typesense_memory_allocated_bytes; + + public string $typesense_memory_fragmentation_ratio; + + public string $typesense_memory_mapped_bytes; + + public string $typesense_memory_metadata_bytes; + + public string $typesense_memory_resident_bytes; + + public string $typesense_memory_retained_bytes; +} diff --git a/src/Objects/Stat.php b/src/Objects/Stat.php new file mode 100644 index 00000000..99fd2a0b --- /dev/null +++ b/src/Objects/Stat.php @@ -0,0 +1,36 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +class Stat extends TypesenseObject +{ + public float $delete_latency_ms; + + public float $delete_requests_per_second; + + public float $import_latency_ms; + + public float $import_requests_per_second; + + public stdClass $latency_ms; + + public float $overloaded_requests_per_second; + + public float $pending_write_batches; + + public stdClass $requests_per_second; + + public float $search_latency_ms; + + public float $search_requests_per_second; + + public float $total_requests_per_second; + + public float $write_latency_ms; + + public float $write_requests_per_second; +} diff --git a/src/Objects/Synonym.php b/src/Objects/Synonym.php new file mode 100644 index 00000000..95305c20 --- /dev/null +++ b/src/Objects/Synonym.php @@ -0,0 +1,24 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +class Synonym extends TypesenseObject +{ + public string $id; + + /** + * @var array<int, string> + */ + public array $synonyms; + + public string $root = ''; + + public string $locale = ''; + + /** + * @var array<int, string> + */ + public array $symbols_to_index = []; +} diff --git a/src/Objects/TypesenseObject.php b/src/Objects/TypesenseObject.php new file mode 100644 index 00000000..f6e8b0c7 --- /dev/null +++ b/src/Objects/TypesenseObject.php @@ -0,0 +1,29 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Objects; + +use stdClass; + +abstract class TypesenseObject +{ + /** + * Constructor. + */ + final public function __construct( + protected readonly stdClass $raw, + ) { + foreach (get_object_vars($this->raw) as $key => $value) { + $this->{$key} = $value; + } + } + + /** + * Create object instance from raw data. + */ + public static function from(stdClass $data): static + { + return new static($data); + } +} diff --git a/src/Operations.php b/src/Operations.php deleted file mode 100644 index 661c5974..00000000 --- a/src/Operations.php +++ /dev/null @@ -1,48 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Operations - * - * @package \Typesense - */ -class Operations -{ - public const RESOURCE_PATH = '/operations'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Alias constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param string $operationName - * @param array $queryParameters - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function perform(string $operationName, array $queryParameters = []): array - { - return $this->apiCall->post( - sprintf('%s/%s', static::RESOURCE_PATH, $operationName), - null, - true, - $queryParameters - ); - } -} diff --git a/src/Override.php b/src/Override.php deleted file mode 100644 index d50855fe..00000000 --- a/src/Override.php +++ /dev/null @@ -1,78 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Override - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Override -{ - - /** - * @var string - */ - private string $collectionName; - - /** - * @var string - */ - private string $overrideId; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * Override constructor. - * - * @param string $collectionName - * @param string $overrideId - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, string $overrideId, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->overrideId = $overrideId; - $this->apiCall = $apiCall; - } - - /** - * @return string - */ - private function endPointPath(): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - Overrides::RESOURCE_PATH, - $this->overrideId - ); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endPointPath()); - } -} diff --git a/src/Overrides.php b/src/Overrides.php deleted file mode 100644 index a7cc48fa..00000000 --- a/src/Overrides.php +++ /dev/null @@ -1,119 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Overrides - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Overrides implements \ArrayAccess -{ - - public const RESOURCE_PATH = 'overrides'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var string - */ - private string $collectionName; - - /** - * @var array - */ - private array $overrides = []; - - /** - * Overrides constructor. - * - * @param string $collectionName - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->apiCall = $apiCall; - } - - /** - * @param string $overrideId - * - * @return string - */ - public function endPointPath(string $overrideId = ''): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - static::RESOURCE_PATH, - $overrideId - ); - } - - /** - * @param string $overrideId - * @param array $config - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function upsert(string $overrideId, array $config): array - { - return $this->apiCall->put($this->endPointPath($overrideId), $config); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @inheritDoc - */ - public function offsetExists($overrideId): bool - { - return isset($this->overrides[$overrideId]); - } - - /** - * @inheritDoc - */ - public function offsetGet($overrideId): Override - { - if (!isset($this->overrides[$overrideId])) { - $this->overrides[$overrideId] = new Override($this->collectionName, $overrideId, $this->apiCall); - } - - return $this->overrides[$overrideId]; - } - - /** - * @inheritDoc - */ - public function offsetSet($overrideId, $value): void - { - $this->overrides[$overrideId] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset($overrideId): void - { - unset($this->overrides[$overrideId]); - } -} diff --git a/src/Presets.php b/src/Presets.php deleted file mode 100644 index b0f56c01..00000000 --- a/src/Presets.php +++ /dev/null @@ -1,107 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Document - * - * @package \Typesense - * @date 4/5/20 - * @author Abdullah Al-Faqeir <abdullah@devloops.net> - */ -class Presets -{ - /** - * @var ApiCall - */ - private $apiCall; - - public const PRESETS_PATH = '/presets'; - - public const MULTI_SEARCH_PATH = '/multi_search'; - - /** - * Document constructor. - * - * @param ApiCall $apiCall - */ - public function __construct(ApiCall $apiCall) - { - $this->apiCall = $apiCall; - } - - /** - * @param $presetName - * @return array|string - * @throws HttpClientException - * @throws TypesenseClientError - */ - public function searchWithPreset($presetName) - { - return $this->apiCall->post($this->multiSearchEndpointPath(), [], true, ['preset' => $presetName]); - } - - /** - * @return array|string - * @throws HttpClientException - * @throws TypesenseClientError - */ - public function get() - { - return $this->apiCall->get(static::PRESETS_PATH, []); - } - - /** - * @param array $options - * - * @return array - * @throws HttpClientException - * @throws TypesenseClientError - */ - public function put(array $options = []) - { - $presetName = $options['preset_name']; - $presetsData = $options['preset_data']; - - return $this->apiCall->put($this->endpointPath($presetName), $presetsData); - } - - /** - * @param $presetName - * @return array - * @throws HttpClientException - * @throws TypesenseClientError - */ - public function delete($presetName) - { - return $this->apiCall->delete($this->endpointPath($presetName)); - } - - /** - * @param $presetsName - * @return string - */ - private function endpointPath($presetsName) - { - return sprintf( - '%s/%s', - static::PRESETS_PATH, - $presetsName - ); - } - - /** - * @param $presetsName - * @return string - */ - private function multiSearchEndpointPath() - { - return sprintf( - '%s', - static::MULTI_SEARCH_PATH - ); - } -} diff --git a/src/Requests/Alias.php b/src/Requests/Alias.php new file mode 100644 index 00000000..acc126b6 --- /dev/null +++ b/src/Requests/Alias.php @@ -0,0 +1,74 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Alias as AliasObject; + +class Alias extends Request +{ + /** + * @param array{ + * collection_name: string, + * } $payload + * + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/collection-alias.html#create-or-update-an-alias + */ + public function upsert(string $name, array $payload): AliasObject + { + $path = sprintf('/aliases/%s', $name); + + $data = $this->send('PUT', $path, $payload); + + return AliasObject::from($data); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/collection-alias.html#retrieve-an-alias + */ + public function retrieve(string $name): AliasObject + { + $path = sprintf('/aliases/%s', $name); + + $data = $this->send('GET', $path); + + return AliasObject::from($data); + } + + /** + * @return array<int, AliasObject> + * + * @see https://typesense.org/docs/latest/api/collection-alias.html#list-all-aliases + */ + public function list(): array + { + $data = $this->send('GET', '/aliases'); + + return array_map( + fn (stdClass $datum) => AliasObject::from($datum), + $data->aliases, + ); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/collection-alias.html#delete-an-alias + */ + public function delete(string $name): bool + { + $path = sprintf('/aliases/%s', $name); + + $data = $this->send('DELETE', $path); + + return $data->name === $name; + } +} diff --git a/src/Requests/Analytic.php b/src/Requests/Analytic.php new file mode 100644 index 00000000..96d99977 --- /dev/null +++ b/src/Requests/Analytic.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceAlreadyExistsException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Analytic as AnalyticObject; + +/** + * @phpstan-import-type RulePayload from AnalyticObject + */ +class Analytic extends Request +{ + /** + * Create a collection for aggregation. + * + * @throws ResourceAlreadyExistsException + * + * @see https://typesense.org/docs/latest/api/analytics-query-suggestions.html#create-a-collection-for-aggregation + */ + public function setup(string $name): bool + { + $data = $this->send('POST', '/collections', [ + 'name' => $name, + 'fields' => [ + ['name' => 'q', 'type' => 'string'], + ['name' => 'count', 'type' => 'int32'], + ], + ]); + + return $data->name === $name; + } + + /** + * @param array{ + * name: string, + * type: 'popular_queries', + * params: RulePayload, + * } $payload + * + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/analytics-query-suggestions.html#create-an-analytics-rule + */ + public function create(array $payload): AnalyticObject + { + $data = $this->send('POST', '/analytics/rules', $payload); + + return AnalyticObject::from($data); + } + + /** + * @return array<int, AnalyticObject> + * + * @see https://typesense.org/docs/latest/api/analytics-query-suggestions.html#list-all-rules + */ + public function list(): array + { + $data = $this->send('GET', '/analytics/rules'); + + return array_map( + fn (stdClass $datum) => AnalyticObject::from($datum), + $data->rules, + ); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/analytics-query-suggestions.html#remove-a-rule + */ + public function delete(string $name): bool + { + $path = sprintf('/analytics/rules/%s', $name); + + $data = $this->send('DELETE', $path); + + return $data->name === $name; + } +} diff --git a/src/Requests/Cluster.php b/src/Requests/Cluster.php new file mode 100644 index 00000000..2f513462 --- /dev/null +++ b/src/Requests/Cluster.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use Typesense\Objects\Metric; +use Typesense\Objects\Stat; + +class Cluster extends Request +{ + /** + * Creates a point-in-time snapshot of a Typesense node's state and data in the specified directory. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#create-snapshot-for-backups + */ + public function snapshot(string $path): bool + { + $path = sprintf('/operations/snapshot?snapshot_path=%s', $path); + + $data = $this->send('POST', $path); + + return $data->success; + } + + /** + * Compacting the on-disk database. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#compacting-the-on-disk-database + */ + public function compact(): bool + { + $data = $this->send('POST', '/operations/db/compact'); + + return $data->success; + } + + /** + * Triggers a follower node to initiate the raft voting process, which triggers leader re-election. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#re-elect-leader + */ + public function reElectLeader(): bool + { + $data = $this->send('POST', '/operations/vote'); + + return $data->success; + } + + /** + * Enable logging of requests that take over a defined threshold of time. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#toggle-slow-request-log + */ + public function updateSlowRequestLog(int $ms): bool + { + $data = $this->send('POST', '/config', [ + 'log-slow-requests-time-ms' => $ms, + ]); + + return $data->success; + } + + /** + * Get current RAM, CPU, Disk & Network usage metrics. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#cluster-metrics + */ + public function metrics(): Metric + { + $data = $this->send('GET', '/metrics.json'); + + return Metric::from($data); + } + + /** + * Get stats about API endpoints. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#api-stats + */ + public function stats(): Stat + { + $data = $this->send('GET', '/stats.json'); + + return Stat::from($data); + } + + /** + * Get health information about a Typesense node. + * + * @see https://typesense.org/docs/latest/api/cluster-operations.html#health + */ + public function health(): bool + { + $data = $this->send('GET', '/health'); + + return $data->ok; + } +} diff --git a/src/Requests/Collection.php b/src/Requests/Collection.php new file mode 100644 index 00000000..6b8b2d30 --- /dev/null +++ b/src/Requests/Collection.php @@ -0,0 +1,142 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceAlreadyExistsException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Collection as CollectionObject; +use Typesense\Objects\CollectionDroppedField; +use Typesense\Objects\CollectionField; + +/** + * @phpstan-import-type TypesenseCollectionFieldLocale from CollectionField + * @phpstan-import-type TypesenseCollectionFieldType from CollectionField + * + * @phpstan-type CollectionFieldCreation array{ + * name: string, + * type: TypesenseCollectionFieldType, + * optional?: bool, + * facet?: bool, + * index?: bool, + * infix?: bool, + * sort?: bool, + * locale?: TypesenseCollectionFieldLocale, + * } + */ +class Collection extends Request +{ + /** + * @param array{ + * name: string, + * fields: non-empty-array<int, CollectionFieldCreation>, + * enable_nested_fields?: bool, + * token_separators?: array<int, string>, + * symbols_to_index?: array<int, string>, + * default_sorting_field?: string, + * } $payload + * + * @throws InvalidPayloadException + * @throws ResourceAlreadyExistsException + * + * @see https://typesense.org/docs/latest/api/collections.html#create-a-collection + */ + public function create(array $payload): CollectionObject + { + $data = $this->send('POST', '/collections', $payload); + + return CollectionObject::from($data); + } + + /** + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/collections.html#cloning-a-collection-schema + */ + public function clone(string $source, string $name): CollectionObject + { + $query = http_build_query([ + 'src_name' => $source, + ]); + + $path = sprintf('/collections?%s', $query); + + $payload = [ + 'name' => $name, + ]; + + $data = $this->send('POST', $path, $payload); + + return CollectionObject::from($data); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/collections.html#retrieve-a-collection + */ + public function retrieve(string $name): CollectionObject + { + $path = sprintf('/collections/%s', $name); + + $data = $this->send('GET', $path); + + return CollectionObject::from($data); + } + + /** + * @return array<int, CollectionObject> + * + * @see https://typesense.org/docs/latest/api/collections.html#list-all-collections + */ + public function list(): array + { + $data = $this->send('GET', '/collections', expectArray: true); + + return array_map( + fn (stdClass $datum) => CollectionObject::from($datum), + $data, + ); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/collections.html#drop-a-collection + */ + public function drop(string $name): CollectionObject + { + $path = sprintf('/collections/%s', $name); + + $data = $this->send('DELETE', $path); + + return CollectionObject::from($data); + } + + /** + * @param array<int, CollectionFieldCreation|array{name: string, drop: true}> $fields + * @return array<int, CollectionField|CollectionDroppedField> + * + * @throws ResourceNotFoundException + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/collections.html#update-or-alter-a-collection + */ + public function update(string $name, array $fields): array + { + $path = sprintf('/collections/%s', $name); + + $data = $this->send('PATCH', $path, ['fields' => $fields]); + + return array_map(function (stdClass $field) { + if (isset($field->drop)) { + return CollectionDroppedField::from($field); + } + + return CollectionField::from($field); + }, $data->fields); + } +} diff --git a/src/Requests/Curation.php b/src/Requests/Curation.php new file mode 100644 index 00000000..c28346b1 --- /dev/null +++ b/src/Requests/Curation.php @@ -0,0 +1,99 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Curation as CurationObject; + +/** + * @phpstan-import-type CurationRule from CurationObject + * @phpstan-import-type CurationExclude from CurationObject + * @phpstan-import-type CurationInclude from CurationObject + */ +class Curation extends Request +{ + /** + * Create or update an override. + * + * @param array{ + * rule: CurationRule, + * includes?: array<int, CurationInclude>, + * excludes?: array<int, CurationExclude>, + * filter_by?: string, + * sort_by?: string, + * replace_query?: string, + * remove_matched_tokens?: bool, + * filter_curated_hits?: bool, + * effective_from_ts?: int, + * effective_to_ts?: int, + * stop_processing?: bool, + * } $payload + * + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/curation.html#create-or-update-an-override + */ + public function upsert(string $collection, string $id, array $payload): CurationObject + { + $path = sprintf('/collections/%s/overrides/%s', $collection, $id); + + $data = $this->send('PUT', $path, $payload); + + return CurationObject::from($data); + } + + /** + * Fetch an individual override associated with a collection. + * + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/curation.html#retrieve-an-override + */ + public function retrieve(string $collection, string $id): CurationObject + { + $path = sprintf('/collections/%s/overrides/%s', $collection, $id); + + $data = $this->send('GET', $path); + + return CurationObject::from($data); + } + + /** + * Listing all overrides associated with a given collection. + * + * @return array<int, CurationObject> + * + * @see https://typesense.org/docs/latest/api/curation.html#list-all-overrides + */ + public function list(string $collection): array + { + $path = sprintf('/collections/%s/overrides', $collection); + + $data = $this->send('GET', $path); + + return array_map( + fn (stdClass $datum) => CurationObject::from($datum), + $data->overrides, + ); + } + + /** + * Deleting an override associated with a collection. + * + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/curation.html#delete-an-override + */ + public function delete(string $collection, string $id): bool + { + $path = sprintf('/collections/%s/overrides/%s', $collection, $id); + + $data = $this->send('DELETE', $path); + + return $data->id === $id; + } +} diff --git a/src/Requests/Document.php b/src/Requests/Document.php new file mode 100644 index 00000000..8ded46c6 --- /dev/null +++ b/src/Requests/Document.php @@ -0,0 +1,268 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Objects\Document as DocumentObject; +use Typesense\Objects\GenericDocument; +use Typesense\Objects\ImportedDocument; + +class Document extends Request +{ + /** + * @template T of DocumentObject + * + * @param array<string, mixed> $payload + * @param class-string<T>|null $document + * @param 'create'|'upsert' $action + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function index( + string $collection, + array $payload, + ?string $document = null, + string $action = 'create', + ): DocumentObject { + $query = http_build_query([ + 'action' => $action, + ]); + + $path = sprintf( + '/collections/%s/documents?%s', + $collection, + $query, + ); + + $data = $this->send('POST', $path, $payload); + + return $this->toDocument($data, $document); + } + + /** + * @template T of DocumentObject + * + * @param array<string, mixed> $payload + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function upsert( + string $collection, + array $payload, + ?string $document = null, + ): DocumentObject { + return $this->index($collection, $payload, $document, 'upsert'); + } + + /** + * @param array<int, array<mixed>> $payloads + * @param 'create'|'upsert'|'update'|'emplace' $action + * @param 'coerce_or_reject'|'coerce_or_drop'|'drop'|'reject' $dirty_values + * @return array<int, ImportedDocument> + */ + public function import( + string $collection, + array $payloads, + string $action = 'create', + bool $return_id = false, + bool $return_doc = false, + string $dirty_values = 'coerce_or_reject', + int $batch_size = 40, + ): array { + $query = http_build_query([ + 'action' => $action, + 'return_id' => $return_id ? 'true' : 'false', + 'return_doc' => $return_doc ? 'true' : 'false', + 'dirty_values' => $dirty_values, + 'batch_size' => $batch_size, + ]); + + $path = sprintf( + '/collections/%s/documents/import?%s', + $collection, + $query, + ); + + $data = $this->send('POST', $path, $payloads, true, true); + + return array_map( + fn (stdClass $datum) => ImportedDocument::from($datum), + $data, + ); + } + + /** + * @template T of DocumentObject + * + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function retrieve( + string $collection, + string $id, + ?string $document = null, + ): DocumentObject { + $path = sprintf( + '/collections/%s/documents/%s', + $collection, + $id, + ); + + $data = $this->send('GET', $path); + + return $this->toDocument($data, $document); + } + + /** + * @template T of DocumentObject + * + * @param array<string, mixed> $payload + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function update( + string $collection, + string $id, + array $payload, + ?string $document = null, + ): DocumentObject { + $path = sprintf( + '/collections/%s/documents/%s', + $collection, + $id, + ); + + $data = $this->send('PATCH', $path, $payload); + + return $this->toDocument($data, $document); + } + + /** + * @param array<mixed> $payload + * @return int The number of total updated documents. + */ + public function updateByQuery( + string $collection, + string $filter_by, + array $payload, + ): int { + $query = http_build_query([ + 'filter_by' => $filter_by, + ]); + + $path = sprintf( + '/collections/%s/documents?%s', + $collection, + $query, + ); + + $data = $this->send('PATCH', $path, $payload); + + return $data->num_updated; + } + + /** + * @template T of DocumentObject + * + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function delete( + string $collection, + string $id, + ?string $document = null, + ): DocumentObject { + $path = sprintf( + '/collections/%s/documents/%s', + $collection, + $id, + ); + + $data = $this->send('DELETE', $path); + + return $this->toDocument($data, $document); + } + + /** + * @return int The number of total deleted documents. + */ + public function deleteByQuery( + string $collection, + string $filter_by, + int $batch_size = 100, + ): int { + $query = http_build_query([ + 'filter_by' => $filter_by, + 'batch_size' => $batch_size, + ]); + + $path = sprintf( + '/collections/%s/documents?%s', + $collection, + $query, + ); + + $data = $this->send('DELETE', $path); + + return $data->num_deleted; + } + + /** + * @template T of DocumentObject + * + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? array<int, T> : array<int, GenericDocument>) + */ + public function export( + string $collection, + string $filter_by = '', + string $include_fields = '', + string $exclude_fields = '', + ?string $document = null, + ): array { + $query = http_build_query( + array_filter( + compact('filter_by', 'include_fields', 'exclude_fields'), + ), + ); + + $path = sprintf( + '/collections/%s/documents/export?%s', + $collection, + $query, + ); + + $data = $this->send( + 'GET', + $path, + expectArray: true, + ndjson: true, + ); + + return array_map( + fn (stdClass $datum) => $this->toDocument($datum, $document), + $data, + ); + } + + /** + * @template T of DocumentObject + * + * @param class-string<T>|null $document + * @return ($document is class-string<T> ? T : GenericDocument) + */ + public function toDocument( + stdClass $data, + ?string $document = null, + ): DocumentObject { + if ( + $document === null || + ! is_subclass_of($document, DocumentObject::class) + ) { + return GenericDocument::from($data); + } + + return new $document($data); + } +} diff --git a/src/Requests/Key.php b/src/Requests/Key.php new file mode 100644 index 00000000..537e3d6d --- /dev/null +++ b/src/Requests/Key.php @@ -0,0 +1,79 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Key as KeyObject; + +/** + * @phpstan-import-type KeyAction from KeyObject + */ +class Key extends Request +{ + /** + * @param array{ + * actions: array<int, KeyAction>, + * collections: array<int, string>, + * description: string, + * value?: string, + * expires_at?: int, + * } $payload + * + * @throws InvalidPayloadException + * + * @see https://typesense.org/docs/latest/api/api-keys.html#create-an-api-key + */ + public function create(array $payload): KeyObject + { + $data = $this->send('POST', '/keys', $payload); + + return KeyObject::from($data); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/api-keys.html#retrieve-an-api-key + */ + public function retrieve(int $id): KeyObject + { + $path = sprintf('/keys/%d', $id); + + $data = $this->send('GET', $path); + + return KeyObject::from($data); + } + + /** + * @return array<int, KeyObject> + * + * @see https://typesense.org/docs/latest/api/api-keys.html#list-all-keys + */ + public function list(): array + { + $data = $this->send('GET', '/keys'); + + return array_map( + fn (stdClass $datum) => KeyObject::from($datum), + $data->keys, + ); + } + + /** + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/api-keys.html#delete-api-key + */ + public function delete(int $id): bool + { + $path = sprintf('/keys/%d', $id); + + $data = $this->send('DELETE', $path); + + return $data->id === $id; + } +} diff --git a/src/Requests/Request.php b/src/Requests/Request.php new file mode 100644 index 00000000..733b33b4 --- /dev/null +++ b/src/Requests/Request.php @@ -0,0 +1,127 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceAlreadyExistsException; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Exceptions\Client\UnauthorizedException; +use Typesense\Exceptions\Client\UnprocessableEntityException; +use Typesense\Exceptions\MalformedResponsePayloadException; +use Typesense\Exceptions\Server\ServiceUnavailableException; +use Typesense\Exceptions\UnknownHttpException; +use Typesense\Http; + +abstract class Request +{ + /** + * Constructor. + */ + public function __construct( + public Http $http, + ) { + // + } + + /** + * @param 'GET'|'POST'|'PATCH'|'PUT'|'DELETE' $method + * @param ($ndjson is true ? array<int, array<string, mixed>> : array<string, mixed>) $body + * @return ($expectArray is false ? stdClass : array<int, stdClass>) + * + * @throws UnauthorizedException + */ + public function send( + string $method, + string $path, + array $body = [], + bool $expectArray = false, + bool $ndjson = false, + ): stdClass|array { + if (! $ndjson) { + $form = json_encode($body) ?: ''; + } else { + $form = array_map( + fn (array $payload) => json_encode($payload), + $body, + ); + + $form = implode(PHP_EOL, array_values(array_filter($form))); + } + + $response = $this->http->request($method, $path, $form); + + $contents = $response->getBody()->getContents(); + + $context = json_decode($contents, false); + + $status = $response->getStatusCode(); + + if (! ($status >= 200 && $status < 300)) { + if (! ($context instanceof stdClass) || ! is_string($context->message)) { + throw new MalformedResponsePayloadException($contents); + } + + $this->toException($status, $context->message); + } + + if ($ndjson) { + return array_map( + function (string $data) { + $result = json_decode($data, false); + + return $result instanceof stdClass ? $result : new stdClass(); + }, + explode(PHP_EOL, $contents), + ); + } + + if ($expectArray) { + if (! is_array($context)) { + throw new MalformedResponsePayloadException($contents); + } + + return $context; + } + + if (! ($context instanceof stdClass)) { + throw new MalformedResponsePayloadException($contents); + } + + return $context; + } + + /** + * Throw exception by the status code. + */ + public function toException(int $status, string $message): void + { + if ($status === 400) { + throw new InvalidPayloadException($message); + } + + if ($status === 401) { + throw new UnauthorizedException('Missing API key or the API key is invalid.'); + } + + if ($status === 404) { + throw new ResourceNotFoundException($message); + } + + if ($status === 409) { + throw new ResourceAlreadyExistsException($message); + } + + if ($status === 422) { + throw new UnprocessableEntityException($message); + } + + if ($status === 503) { + throw new ServiceUnavailableException(); + } + + throw new UnknownHttpException(); + } +} diff --git a/src/Requests/Synonym.php b/src/Requests/Synonym.php new file mode 100644 index 00000000..9733e42b --- /dev/null +++ b/src/Requests/Synonym.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Requests; + +use stdClass; +use Typesense\Exceptions\Client\ResourceNotFoundException; +use Typesense\Objects\Synonym as SynonymObject; + +class Synonym extends Request +{ + /** + * Create or update a synonym. + * + * @param array{ + * synonyms: array<int, string>, + * root?: string, + * locale?: string, + * symbols_to_index?: array<int, string>, + * } $payload + * + * @see https://typesense.org/docs/latest/api/synonyms.html#create-or-update-a-synonym + */ + public function upsert(string $collection, string $id, array $payload): SynonymObject + { + $path = sprintf('/collections/%s/synonyms/%s', $collection, $id); + + $data = $this->send('PUT', $path, $payload); + + return SynonymObject::from($data); + } + + /** + * Retrieve a single synonym. + * + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/synonyms.html#retrieve-a-synonym + */ + public function retrieve(string $collection, string $id): SynonymObject + { + $path = sprintf('/collections/%s/synonyms/%s', $collection, $id); + + $data = $this->send('GET', $path); + + return SynonymObject::from($data); + } + + /** + * List all synonyms associated with a given collection. + * + * @return array<int, SynonymObject> + * + * @see https://typesense.org/docs/latest/api/synonyms.html#list-all-synonyms + */ + public function list(string $collection): array + { + $path = sprintf('/collections/%s/synonyms', $collection); + + $data = $this->send('GET', $path); + + return array_map( + fn (stdClass $datum) => SynonymObject::from($datum), + $data->synonyms, + ); + } + + /** + * Delete a synonym associated with a collection. + * + * @throws ResourceNotFoundException + * + * @see https://typesense.org/docs/latest/api/synonyms.html#delete-a-synonym + */ + public function delete(string $collection, string $id): bool + { + $path = sprintf('/collections/%s/synonyms/%s', $collection, $id); + + $data = $this->send('DELETE', $path); + + return $data->id === $id; + } +} diff --git a/src/Synonym.php b/src/Synonym.php deleted file mode 100644 index 5ce46c15..00000000 --- a/src/Synonym.php +++ /dev/null @@ -1,76 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class synonym - * - * @package \Typesense - */ -class Synonym -{ - - /** - * @var string - */ - private string $collectionName; - - /** - * @var string - */ - private string $synonymId; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * synonym constructor. - * - * @param string $collectionName - * @param string $synonymId - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, string $synonymId, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->synonymId = $synonymId; - $this->apiCall = $apiCall; - } - - /** - * @return string - */ - private function endPointPath(): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - synonyms::RESOURCE_PATH, - $this->synonymId - ); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function delete(): array - { - return $this->apiCall->delete($this->endPointPath()); - } -} diff --git a/src/Synonyms.php b/src/Synonyms.php deleted file mode 100644 index f48f2144..00000000 --- a/src/Synonyms.php +++ /dev/null @@ -1,117 +0,0 @@ -<?php - -namespace Typesense; - -use Http\Client\Exception as HttpClientException; -use Typesense\Exceptions\TypesenseClientError; - -/** - * Class Synonyms - * - * @package \Typesense - */ -class Synonyms implements \ArrayAccess -{ - - public const RESOURCE_PATH = 'synonyms'; - - /** - * @var ApiCall - */ - private ApiCall $apiCall; - - /** - * @var string - */ - private string $collectionName; - - /** - * @var array - */ - private array $synonyms = []; - - /** - * Synonyms constructor. - * - * @param string $collectionName - * @param ApiCall $apiCall - */ - public function __construct(string $collectionName, ApiCall $apiCall) - { - $this->collectionName = $collectionName; - $this->apiCall = $apiCall; - } - - /** - * @param string $synonymId - * - * @return string - */ - public function endPointPath(string $synonymId = ''): string - { - return sprintf( - '%s/%s/%s/%s', - Collections::RESOURCE_PATH, - $this->collectionName, - static::RESOURCE_PATH, - $synonymId - ); - } - - /** - * @param string $synonymId - * @param array $config - * - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function upsert(string $synonymId, array $config): array - { - return $this->apiCall->put($this->endPointPath($synonymId), $config); - } - - /** - * @return array - * @throws TypesenseClientError|HttpClientException - */ - public function retrieve(): array - { - return $this->apiCall->get($this->endPointPath(), []); - } - - /** - * @inheritDoc - */ - public function offsetExists($synonymId): bool - { - return isset($this->synonyms[$synonymId]); - } - - /** - * @inheritDoc - */ - public function offsetGet($synonymId): Synonym - { - if (!isset($this->synonyms[$synonymId])) { - $this->synonyms[$synonymId] = new Synonym($this->collectionName, $synonymId, $this->apiCall); - } - - return $this->synonyms[$synonymId]; - } - - /** - * @inheritDoc - */ - public function offsetSet($synonymId, $value): void - { - $this->synonyms[$synonymId] = $value; - } - - /** - * @inheritDoc - */ - public function offsetUnset($synonymId): void - { - unset($this->synonyms[$synonymId]); - } -} diff --git a/src/Typesense.php b/src/Typesense.php new file mode 100644 index 00000000..0ebe2cd7 --- /dev/null +++ b/src/Typesense.php @@ -0,0 +1,88 @@ +<?php + +declare(strict_types=1); + +namespace Typesense; + +use Psr\Http\Client\ClientInterface; +use Typesense\Requests\Alias; +use Typesense\Requests\Analytic; +use Typesense\Requests\Cluster; +use Typesense\Requests\Collection; +use Typesense\Requests\Curation; +use Typesense\Requests\Document; +use Typesense\Requests\Key; +use Typesense\Requests\Synonym; + +/** + * @phpstan-type TypesenseConfiguration array{ + * url: string, + * apiKey: string, + * http?: ClientInterface|null, + * } + */ +class Typesense +{ + public readonly Http $http; + + public readonly Collection $collection; + + public readonly Document $document; + + public readonly Analytic $analytic; + + public readonly Key $key; + + public readonly Curation $curation; + + public readonly Alias $alias; + + public readonly Synonym $synonym; + + public readonly Cluster $cluster; + + /** + * @param TypesenseConfiguration $config + */ + public function __construct(array $config) + { + $this->http = new Http($config); + + $this->collection = new Collection($this->http); + + $this->document = new Document($this->http); + + $this->analytic = new Analytic($this->http); + + $this->key = new Key($this->http); + + $this->curation = new Curation($this->http); + + $this->alias = new Alias($this->http); + + $this->synonym = new Synonym($this->http); + + $this->cluster = new Cluster($this->http); + } + + public function setUrl(string $url): static + { + $this->http->config['url'] = $url; + + return $this; + } + + public function setApiKey(string $key): static + { + $this->http->config['apiKey'] = $key; + + return $this; + } + + public function setHttp(ClientInterface $http): static + { + $this->http->config['http'] = $http; + + return $this; + } +} diff --git a/tests/Objects/TestObject.php b/tests/Objects/TestObject.php new file mode 100644 index 00000000..f7020abe --- /dev/null +++ b/tests/Objects/TestObject.php @@ -0,0 +1,14 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Objects; + +use Typesense\Objects\Document; + +final class TestObject extends Document +{ + public string $name; + + public string $description; +} diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 00000000..e495ea5e --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,7 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests; + +uses(TestCase::class)->in('Unit'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 00000000..80d36c1f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,70 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests; + +use PHPUnit\Framework\TestCase as BaseTestCase; +use Typesense\Typesense; + +use function Pest\Faker\fake; + +abstract class TestCase extends BaseTestCase +{ + public Typesense $typesense; + + public string $collectionName = 'testing'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void + { + parent::setUp(); + + $this->typesense = new Typesense([ + 'url' => 'http://localhost:8108', + 'apiKey' => 'testing', + ]); + + $this->createTestingCollection(); + } + + /** + * {@inheritdoc} + */ + protected function tearDown(): void + { + $this->typesense->collection->drop($this->collectionName); + + parent::tearDown(); + } + + /** + * Create testing collection. + */ + protected function createTestingCollection(): void + { + $this->typesense->collection->create([ + 'name' => $this->collectionName, + 'fields' => [ + [ + 'name' => 'name', + 'type' => 'string', + ], + [ + 'name' => 'description', + 'type' => 'string', + ], + ], + ]); + } + + /** + * Generate random slug. + */ + public function slug(): string + { + return fake()->unique()->slug(variableNbWords: false); + } +} diff --git a/tests/Unit/AliasTest.php b/tests/Unit/AliasTest.php new file mode 100644 index 00000000..eafe2f37 --- /dev/null +++ b/tests/Unit/AliasTest.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can create an alias to an existing collection', function () { + $alias = $this->typesense->alias->upsert( + $name = $this->slug(), + [ + 'collection_name' => $this->collectionName, + ], + ); + + expect($alias->name)->toBe($name); + + expect($alias->collection_name)->toBe($this->collectionName); +}); + +test('it can create an alias to a non-existent collection', function () { + $alias = $this->typesense->alias->upsert( + $name = $this->slug(), + [ + 'collection_name' => $collection = $this->slug(), + ], + ); + + expect($alias->name)->toBe($name); + + expect($alias->collection_name)->toBe($collection); +}); + +test('it can not create an alias without collection name', function () { + $this->typesense->alias->upsert( + $this->slug(), + [], + ); +})->throws(InvalidPayloadException::class); + +test('it can retrieve an existing alias', function () { + $alias = $this->typesense->alias->upsert( + $this->slug(), + [ + 'collection_name' => $this->collectionName, + ], + ); + + $nAlias = $this->typesense->alias->retrieve($alias->name); + + expect($nAlias->name)->toBe($alias->name); +}); + +test('it can not retrieve a non-existent alias', function () { + $this->typesense->alias->retrieve( + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); + +test('it can list all aliases', function () { + $alias = $this->typesense->alias->upsert( + $this->slug(), + [ + 'collection_name' => $this->collectionName, + ], + ); + + $aliases = $this->typesense->alias->list(); + + $names = array_column($aliases, 'name'); + + expect($alias->name)->toBeIn($names); +}); + +test('it can delete an existing alias', function () { + $alias = $this->typesense->alias->upsert( + $this->slug(), + [ + 'collection_name' => $this->collectionName, + ], + ); + + $deleted = $this->typesense->alias->delete($alias->name); + + expect($deleted)->toBeTrue(); +}); + +test('it can not delete a non-existent alias', function () { + $this->typesense->alias->delete( + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); diff --git a/tests/Unit/AnalyticTest.php b/tests/Unit/AnalyticTest.php new file mode 100644 index 00000000..8fd67ff7 --- /dev/null +++ b/tests/Unit/AnalyticTest.php @@ -0,0 +1,125 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceAlreadyExistsException; +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can use setup to create aggregate collection', function () { + $created = $this->typesense->analytic->setup( + $this->slug(), + ); + + expect($created)->toBeTrue(); +}); + +test('it can not use setup to create aggregate collection with existing collection name', function () { + $this->typesense->analytic->setup( + $name = $this->slug(), + ); + + $this->typesense->analytic->setup( + $name, + ); +})->throws(ResourceAlreadyExistsException::class); + +test('it can create an analytic rule', function () { + $this->typesense->analytic->setup( + $collection = $this->slug(), + ); + + $rule = $this->typesense->analytic->create([ + 'name' => $name = $this->slug(), + 'type' => 'popular_queries', + 'params' => [ + 'source' => [ + 'collections' => ['*'], + ], + 'destination' => [ + 'collection' => $collection, + ], + 'limit' => 50, + ], + ]); + + expect($rule->name)->toBe($name); +}); + +test('it can not create an analytic rule with invalid type', function () { + $this->typesense->analytic->setup( + $collection = $this->slug(), + ); + + $this->typesense->analytic->create([ + 'name' => $this->slug(), + 'type' => $this->slug(), + 'params' => [ + 'source' => [ + 'collections' => ['*'], + ], + 'destination' => [ + 'collection' => $collection, + ], + 'limit' => 50, + ], + ]); +})->throws(InvalidPayloadException::class); + +test('it can list all analytic rules', function () { + $this->typesense->analytic->setup( + $collection = $this->slug(), + ); + + $analytic = $this->typesense->analytic->create([ + 'name' => $this->slug(), + 'type' => 'popular_queries', + 'params' => [ + 'source' => [ + 'collections' => ['*'], + ], + 'destination' => [ + 'collection' => $collection, + ], + 'limit' => 50, + ], + ]); + + $analytics = $this->typesense->analytic->list(); + + $names = array_column($analytics, 'name'); + + expect($analytic->name)->toBeIn($names); +}); + +test('it can delete an existing analytic rule', function () { + $this->typesense->analytic->setup( + $collection = $this->slug(), + ); + + $analytic = $this->typesense->analytic->create([ + 'name' => $this->slug(), + 'type' => 'popular_queries', + 'params' => [ + 'source' => [ + 'collections' => ['*'], + ], + 'destination' => [ + 'collection' => $collection, + ], + 'limit' => 50, + ], + ]); + + $deleted = $this->typesense->analytic->delete($analytic->name); + + expect($deleted)->toBeTrue(); +}); + +test('it can not delete a non-existent analytic rule', function () { + $this->typesense->analytic->delete( + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); diff --git a/tests/Unit/ArchitectureTest.php b/tests/Unit/ArchitectureTest.php new file mode 100644 index 00000000..d17b62a4 --- /dev/null +++ b/tests/Unit/ArchitectureTest.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\TypesenseException; + +test('there are no debugging statements remaining in the code') + ->expect(['dd', 'dump', 'ray', 'var_dump', 'echo', 'print_r']) + ->not() + ->toBeUsed(); + +test('strict typing must be enforced in the code') + ->expect('Typesense') + ->toUseStrictTypes(); + +test('the code should not utilize the "final" keyword') + ->expect('Typesense') + ->not() + ->toBeFinal(); + +test('all exception classes should extend "TypesenseException"') + ->expect('Typesense\Exceptions') + ->classes() + ->toExtend(TypesenseException::class); diff --git a/tests/Unit/AuthenticationTest.php b/tests/Unit/AuthenticationTest.php new file mode 100644 index 00000000..a8d23948 --- /dev/null +++ b/tests/Unit/AuthenticationTest.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\UnauthorizedException; + +test('it must set a valid api key when calling API', function () { + $origin = $this->typesense->http->config['apiKey']; + + $this->typesense->setApiKey($this->slug()); + + expect(fn () => $this->typesense->collection->list()) + ->toThrow(UnauthorizedException::class); + + $this->typesense->setApiKey($origin); +}); diff --git a/tests/Unit/ClusterTest.php b/tests/Unit/ClusterTest.php new file mode 100644 index 00000000..5b12c39d --- /dev/null +++ b/tests/Unit/ClusterTest.php @@ -0,0 +1,45 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +test('it can create a snapshot', function () { + $path = sprintf('/tmp/%s', $this->slug()); + + $success = $this->typesense->cluster->snapshot( + $path, + ); + + expect($success)->toBeTrue(); +}); + +test('it can compact the database', function () { + $success = $this->typesense->cluster->compact(); + + expect($success)->toBeTrue(); +}); + +test('it can update slow request log', function () { + $success = $this->typesense->cluster->updateSlowRequestLog(100); + + expect($success)->toBeTrue(); +}); + +test('it can get metrics', function () { + $metric = $this->typesense->cluster->metrics(); + + expect($metric->typesense_memory_retained_bytes)->toBeGreaterThanOrEqual(0); +}); + +test('it can get stats', function () { + $stats = $this->typesense->cluster->stats(); + + expect($stats->delete_latency_ms)->toBeGreaterThanOrEqual(0); +}); + +test('it can get health', function () { + $health = $this->typesense->cluster->health(); + + expect($health)->toBeTrue(); +}); diff --git a/tests/Unit/CollectionTest.php b/tests/Unit/CollectionTest.php new file mode 100644 index 00000000..e1c12599 --- /dev/null +++ b/tests/Unit/CollectionTest.php @@ -0,0 +1,223 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceAlreadyExistsException; +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can create collection', function () { + $collectionName = $this->slug(); + + $fieldName = $this->slug(); + + $fieldType = 'string'; + + $collection = $this->typesense->collection->create([ + 'name' => $collectionName, + 'fields' => [ + [ + 'name' => $fieldName, + 'type' => $fieldType, + ], + ], + ]); + + expect($collection->created_at)->toBeInt(); + + expect($collection->default_sorting_field)->toBeEmpty(); + + expect($collection->enable_nested_fields)->toBeFalse(); + + expect($collection->name)->toBe($collectionName); + + expect($collection->num_documents)->toBe(0); + + expect($collection->symbols_to_index)->toBeEmpty(); + + expect($collection->token_separators)->toBeEmpty(); + + expect($collection->fields)->toHaveCount(1); + + $field = $collection->fields[0]; + + expect($field->facet)->toBeFalse(); + + expect($field->index)->toBeTrue(); + + expect($field->infix)->toBeFalse(); + + expect($field->locale)->toBeEmpty(); + + expect($field->name)->toBe($fieldName); + + expect($field->optional)->toBeFalse(); + + expect($field->sort)->toBeFalse(); + + expect($field->type)->toBe($fieldType); +}); + +test('it can not create collection if the name already exists', function () { + $this->typesense->collection->create([ + 'name' => $this->collectionName, + 'fields' => [ + [ + 'name' => $this->slug(), + 'type' => 'string', + ], + ], + ]); +})->throws(ResourceAlreadyExistsException::class); + +test('it can not create collection if the name is empty', function () { + $this->typesense->collection->create([ + 'name' => '', + ]); +})->throws(InvalidPayloadException::class); + +test('it can not create collection if the fields is empty', function () { + $this->typesense->collection->create([ + 'name' => $this->slug(), + 'fields' => [], + ]); +})->throws(InvalidPayloadException::class); + +test('it can not create collection if the fields are invalid', function () { + $this->typesense->collection->create([ + 'name' => $this->slug(), + 'fields' => [ + [ + 'name' => '', + 'type' => 'string', + ], + ], + ]); +})->throws(InvalidPayloadException::class); + +test('it can clone collection', function () { + $target = $this->slug(); + + $collection = $this->typesense + ->collection + ->clone($this->collectionName, $target); + + expect($collection->name)->toBe($target); +}); + +test('it can not clone collection if source collection does not exist', function () { + $this->typesense + ->collection + ->clone($this->slug(), $this->slug()); +})->throws(InvalidPayloadException::class); + +test('it can not clone collection if target collection already exists', function () { + $this->typesense + ->collection + ->clone($this->collectionName, $this->collectionName); +})->throws(InvalidPayloadException::class); + +test('it can retrieve collection', function () { + $collection = $this->typesense + ->collection + ->retrieve($this->collectionName); + + expect($collection->name)->toBe($this->collectionName); +}); + +test('it can not retrieve a non-existent collection', function () { + $this->typesense + ->collection + ->retrieve($this->slug()); +})->throws(ResourceNotFoundException::class); + +test('it can list collection', function () { + $collections = $this->typesense + ->collection + ->list(); + + expect($collections) + ->toBeArray() + ->toBeGreaterThanOrEqual(1); +}); + +test('it can drop collection', function () { + $name = $this->slug(); + + $this->typesense->collection->create([ + 'name' => $name, + 'fields' => [ + [ + 'name' => $this->slug(), + 'type' => 'string', + ], + ], + ]); + + $collection = $this->typesense->collection->drop($name); + + expect($collection->name)->toBe($name); +}); + +test('it can not drop a non-existent collection', function () { + $this->typesense->collection->drop($this->slug()); +})->throws(ResourceNotFoundException::class); + +test('it can update collection', function () { + $name = $this->slug(); + + $fields = $this->typesense->collection->update($this->collectionName, [ + [ + 'name' => $name, + 'type' => 'int32', + ], + ]); + + expect($fields)->toHaveCount(1); + + expect($fields[0]->name)->toBe($name); + + expect($fields[0]->type)->toBe('int32'); + + $fields = $this->typesense->collection->update($this->collectionName, [ + [ + 'name' => $name, + 'drop' => true, + ], + ]); + + expect($fields)->toHaveCount(1); + + expect($fields[0]->name)->toBe($name); + + expect($fields[0]->drop)->toBeTrue(); +}); + +test('it can not update a non-existent collection', function () { + $this->typesense->collection->update($this->slug(), [ + [ + 'name' => $this->slug(), + 'type' => 'int32', + ], + ]); +})->throws(ResourceNotFoundException::class); + +test('it can not update an existing collection field', function () { + $this->typesense->collection->update($this->collectionName, [ + [ + 'name' => 'name', + 'type' => 'int32', + ], + ]); +})->throws(InvalidPayloadException::class); + +test('it can not set drop to "false" for an existing collection field', function () { + $this->typesense->collection->update($this->collectionName, [ + [ + 'name' => 'name', + 'drop' => false, + ], + ]); +})->throws(InvalidPayloadException::class); diff --git a/tests/Unit/CurationTest.php b/tests/Unit/CurationTest.php new file mode 100644 index 00000000..917e4371 --- /dev/null +++ b/tests/Unit/CurationTest.php @@ -0,0 +1,113 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can create a curation', function () { + $curation = $this->typesense->curation->upsert( + $this->collectionName, + $id = $this->slug(), + [ + 'rule' => [ + 'query' => 'apple', + 'match' => 'contains', + ], + 'remove_matched_tokens' => true, + ], + ); + + expect($curation->id)->toBe($id); +}); + +test('it can not create a curation without required fields', function () { + $this->typesense->curation->upsert( + $this->collectionName, + $this->slug(), + [ + 'rule' => [ + 'query' => 'apple', + 'match' => 'contains', + ], + ], + ); +})->throws(InvalidPayloadException::class); + +test('it can retrieve an existing curation', function () { + $this->typesense->curation->upsert( + $this->collectionName, + $id = $this->slug(), + [ + 'rule' => [ + 'query' => 'apple', + 'match' => 'contains', + ], + 'remove_matched_tokens' => true, + ], + ); + + $curation = $this->typesense->curation->retrieve( + $this->collectionName, + $id, + ); + + expect($curation->id)->toBe($id); +}); + +test('it can not retrieve a non--existent curation', function () { + $this->typesense->curation->retrieve( + $this->collectionName, + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); + +test('it can list all curations', function () { + $curation = $this->typesense->curation->upsert( + $this->collectionName, + $this->slug(), + [ + 'rule' => [ + 'query' => 'apple', + 'match' => 'contains', + ], + 'remove_matched_tokens' => true, + ], + ); + + $curations = $this->typesense->curation->list($this->collectionName); + + $ids = array_column($curations, 'id'); + + expect($curation->id)->toBeIn($ids); +}); + +test('it can delete an existing curation', function () { + $curation = $this->typesense->curation->upsert( + $this->collectionName, + $this->slug(), + [ + 'rule' => [ + 'query' => 'apple', + 'match' => 'contains', + ], + 'remove_matched_tokens' => true, + ], + ); + + $deleted = $this->typesense->curation->delete( + $this->collectionName, + $curation->id, + ); + + expect($deleted)->toBeTrue(); +}); + +test('it can not delete a non-existent curation', function () { + $this->typesense->curation->delete( + $this->collectionName, + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); diff --git a/tests/Unit/DocumentTest.php b/tests/Unit/DocumentTest.php new file mode 100644 index 00000000..ea9a1f7b --- /dev/null +++ b/tests/Unit/DocumentTest.php @@ -0,0 +1,276 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Tests\Objects\TestObject; + +test('it can index document', function () { + $name = $this->slug(); + + $description = $this->slug(); + + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $name, + 'description' => $description, + ], + TestObject::class, + ); + + expect($document->name)->toBe($name); + + expect($document->description)->toBe($description); +}); + +test('it can upsert document', function () { + $name = $this->slug(); + + $description = $this->slug(); + + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $name, + 'description' => $description, + ], + TestObject::class, + ); + + expect($document->name)->toBe($name); + + expect($document->description)->toBe($description); + + $name = $this->slug(); + + $description = $this->slug(); + + $document = $this->typesense->document->upsert( + 'testing', + [ + 'id' => $document->id, + 'name' => $name, + 'description' => $description, + ], + TestObject::class, + ); + + expect($document->name)->toBe($name); + + expect($document->description)->toBe($description); +}); + +test('it can import document', function () { + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + TestObject::class, + ); + + $documents = [ + [ + 'id' => $document->id, + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + ]; + + $results = $this->typesense->document->import( + 'testing', + $documents, + return_id: true, + return_doc: true, + ); + + expect($results)->toHaveCount(count($documents)); + + expect($results[0]->success)->toBeFalse(); + + expect($results[1]->success)->toBeTrue(); + + expect($results[2]->success)->toBeTrue(); +}); + +test('it can retrieve document', function () { + $name = $this->slug(); + + $description = $this->slug(); + + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $name, + 'description' => $description, + ], + TestObject::class, + ); + + $document = $this->typesense->document->retrieve('testing', $document->id); + + expect($document->name)->toBe($name); + + expect($document->description)->toBe($description); +}); + +test('it can update document', function () { + $name = $this->slug(); + + $description = $this->slug(); + + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $name, + 'description' => $description, + ], + TestObject::class, + ); + + $description = $this->slug(); + + expect($document->description)->not()->toBe($description); + + $document = $this->typesense->document->update( + 'testing', + $document->id, + [ + 'description' => $description, + ], + ); + + expect($document->name)->toBe($name); + + expect($document->description)->toBe($description); +}); + +test('it can update documents by query', function () { + $documents = [ + [ + 'id' => '1', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'id' => '2', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'id' => '3', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + ]; + + $imported = $this->typesense->document->import( + 'testing', + $documents, + ); + + expect($imported)->toHaveCount(count($documents)); + + $description = $this->slug(); + + $updated = $this->typesense->document->updateByQuery( + 'testing', + 'id:[2,3]', + [ + 'description' => $description, + ], + ); + + expect($updated)->toBe(2); +}); + +test('it can delete document', function () { + $document = $this->typesense->document->index( + 'testing', + [ + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + TestObject::class, + ); + + $id = $document->id; + + $document = $this->typesense->document->delete('testing', $id); + + expect($document->id)->toBe($id); +}); + +test('it can delete documents by query', function () { + $documents = [ + [ + 'id' => '1', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'id' => '2', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'id' => '3', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + ]; + + $imported = $this->typesense->document->import( + 'testing', + $documents, + ); + + expect($imported)->toHaveCount(count($documents)); + + $deleted = $this->typesense->document->deleteByQuery( + 'testing', + 'id:[2,3]', + ); + + expect($deleted)->toBe(2); +}); + +test('it can export documents', function () { + $documents = [ + [ + 'id' => '1', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + [ + 'id' => '2', + 'name' => $this->slug(), + 'description' => $this->slug(), + ], + ]; + + $imported = $this->typesense->document->import( + 'testing', + $documents, + ); + + expect($imported)->toHaveCount(count($documents)); + + $exports = $this->typesense->document->export( + 'testing', + document: TestObject::class, + ); + + expect($exports)->toHaveCount(count($documents)); +}); diff --git a/tests/Unit/HttpClientTest.php b/tests/Unit/HttpClientTest.php new file mode 100644 index 00000000..27b6401e --- /dev/null +++ b/tests/Unit/HttpClientTest.php @@ -0,0 +1,33 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use GuzzleHttp\Client; +use Symfony\Component\HttpClient\Psr18Client; +use Typesense\Objects\Collection; + +test('it can use symfony http client', function () { + $this->typesense->setHttp( + new Psr18Client(), + ); + + $collection = $this->typesense->collection->retrieve( + $this->collectionName, + ); + + expect($collection)->toBeInstanceOf(Collection::class); +}); + +test('it can use guzzle http client', function () { + $this->typesense->setHttp( + new Client(), + ); + + $collection = $this->typesense->collection->retrieve( + $this->collectionName, + ); + + expect($collection)->toBeInstanceOf(Collection::class); +}); diff --git a/tests/Unit/KeyTest.php b/tests/Unit/KeyTest.php new file mode 100644 index 00000000..daa2a65e --- /dev/null +++ b/tests/Unit/KeyTest.php @@ -0,0 +1,106 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\InvalidPayloadException; +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can create an api key', function () { + $key = $this->typesense->key->create([ + 'actions' => ['collections:*'], + 'collections' => ['*'], + 'description' => $description = $this->slug(), + 'expires_at' => $expires_at = time() + 100, + ]); + + expect($key->actions)->toBe(['collections:*']); + + expect($key->collections)->toBe(['*']); + + expect($key->description)->toBe($description); + + expect($key->expires_at)->toBe($expires_at); + + expect($key->id)->toBeInt(); + + expect($key->value)->toBeString(); + + expect($key->value_prefix)->toBeNull(); +}); + +test('it can not create an api key without actions', function () { + $this->typesense->key->create([ + 'actions' => [], + 'collections' => ['*'], + 'description' => $this->slug(), + ]); +})->throws(InvalidPayloadException::class); + +test('it can not create an api key without collections', function () { + $this->typesense->key->create([ + 'actions' => ['*'], + 'collections' => [], + 'description' => $this->slug(), + ]); +})->throws(InvalidPayloadException::class); + +test('it can create an api key without description', function () { + $this->typesense->key->create([ + 'actions' => ['*'], + 'collections' => ['*'], + ]); +})->throws(InvalidPayloadException::class); + +test('it can retrieve an existing key', function () { + $key = $this->typesense->key->create([ + 'actions' => ['*'], + 'collections' => ['*'], + 'description' => $this->slug(), + ]); + + $nKey = $this->typesense->key->retrieve($key->id); + + expect($nKey->id)->toBe($key->id); + + expect($nKey->expires_at)->toBe($key->expires_at); +}); + +test('it can not retrieve a non-existent key', function () { + $this->typesense->key->retrieve( + mt_rand(1000000000, 2147483647), + ); +})->throws(ResourceNotFoundException::class); + +test('it can list all api keys', function () { + $key = $this->typesense->key->create([ + 'actions' => ['*'], + 'collections' => ['*'], + 'description' => $this->slug(), + ]); + + $keys = $this->typesense->key->list(); + + $ids = array_column($keys, 'id'); + + expect($key->id)->toBeIn($ids); +}); + +test('it can delete an existing key', function () { + $key = $this->typesense->key->create([ + 'actions' => ['*'], + 'collections' => ['*'], + 'description' => $this->slug(), + ]); + + $deleted = $this->typesense->key->delete($key->id); + + expect($deleted)->toBeTrue(); +}); + +test('it can not delete a non-existent key', function () { + $this->typesense->key->delete( + mt_rand(1000000000, 2147483647), + ); +})->throws(ResourceNotFoundException::class); diff --git a/tests/Unit/MiscellaneousTest.php b/tests/Unit/MiscellaneousTest.php new file mode 100644 index 00000000..a54b8d3f --- /dev/null +++ b/tests/Unit/MiscellaneousTest.php @@ -0,0 +1,18 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +test('it will throw error', function () { + $origin = $this->typesense->http->config['url']; + + $this->typesense->setUrl('http://0.0.0.0:1'); + + expect( + fn () => $this->typesense->collection->retrieve('testing'), + ) + ->toThrow('0.0.0.0'); + + $this->typesense->setUrl($origin); +}); diff --git a/tests/Unit/ObjectCreationTest.php b/tests/Unit/ObjectCreationTest.php new file mode 100644 index 00000000..fd7088dd --- /dev/null +++ b/tests/Unit/ObjectCreationTest.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use stdClass; +use Typesense\Tests\Objects\TestObject; + +test('it will automatically assign properties', function () { + $data = new stdClass(); + + $data->name = 'Hello'; + + $data->description = 'World'; + + $object = TestObject::from($data); + + expect($object->name)->toBe($data->name); + + expect($object->description)->toBe($data->description); +}); diff --git a/tests/Unit/SynonymTest.php b/tests/Unit/SynonymTest.php new file mode 100644 index 00000000..b7637a80 --- /dev/null +++ b/tests/Unit/SynonymTest.php @@ -0,0 +1,104 @@ +<?php + +declare(strict_types=1); + +namespace Typesense\Tests\Unit; + +use Typesense\Exceptions\Client\ResourceNotFoundException; + +test('it can create a multi-way synonym', function () { + $synonym = $this->typesense->synonym->upsert( + $this->collectionName, + $id = $this->slug(), + [ + 'synonyms' => $synonyms = ['apple', 'banana', 'car'], + ], + ); + + expect($synonym->id)->toBe($id); + + expect($synonym->synonyms)->toBe($synonyms); + + expect($synonym->root)->toBe(''); +}); + +test('it can create a one-way synonym', function () { + $synonym = $this->typesense->synonym->upsert( + $this->collectionName, + $id = $this->slug(), + [ + 'root' => $root = 'dog', + 'synonyms' => $synonyms = ['apple', 'banana', 'car'], + ], + ); + + expect($synonym->id)->toBe($id); + + expect($synonym->synonyms)->toBe($synonyms); + + expect($synonym->root)->toBe($root); +}); + +test('it can retrieve an existing synonym', function () { + $this->typesense->synonym->upsert( + $this->collectionName, + $id = $this->slug(), + [ + 'synonyms' => [$this->slug()], + ], + ); + + $synonym = $this->typesense->synonym->retrieve( + $this->collectionName, + $id, + ); + + expect($synonym->id)->toBe($id); +}); + +test('it can not retrieve a non-existent synonym', function () { + $this->typesense->synonym->retrieve( + $this->collectionName, + $this->slug(), + ); +})->throws(ResourceNotFoundException::class); + +test('it can list all synonyms', function () { + $synonym = $this->typesense->synonym->upsert( + $this->collectionName, + $this->slug(), + [ + 'synonyms' => [$this->slug()], + ], + ); + + $synonyms = $this->typesense->synonym->list($this->collectionName); + + $ids = array_column($synonyms, 'id'); + + expect($synonym->id)->toBeIn($ids); +}); + +test('it can delete an existing synonym', function () { + $synonym = $this->typesense->synonym->upsert( + $this->collectionName, + $this->slug(), + [ + 'synonyms' => [$this->slug()], + ], + ); + + $deleted = $this->typesense->synonym->delete( + $this->collectionName, + $synonym->id, + ); + + expect($deleted)->toBeTrue(); +}); + +test('it can not delete a non-existent synonym', function () { + $this->typesense->synonym->delete( + $this->collectionName, + $this->slug(), + ); +})->throws(ResourceNotFoundException::class);