From a3e578990aca7c56cd919b02dd2c680b928e7484 Mon Sep 17 00:00:00 2001 From: Pavel Ptacek Date: Fri, 27 Jul 2018 11:24:44 +0200 Subject: [PATCH] Implement support for Item Properties in Recommendation Requests --- CHANGELOG.md | 6 ++ README.md | 58 ++++++++++++++++++- src/Model/Command/UserRecommendation.php | 49 ++++++++++++++-- .../RecommendationRequestBuilderTest.php | 21 ++++++- .../Fixtures/response-recommendation.json | 38 ++++++++++++ tests/unit/Http/ResponseDecoderTest.php | 19 ++++++ .../Model/Command/UserRecommendationTest.php | 18 ++++++ 7 files changed, 202 insertions(+), 7 deletions(-) create mode 100644 tests/unit/Http/Fixtures/response-recommendation.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 678bbe8..cb42abe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,15 @@ ## Unreleased +### Changed +- **BC BREAK** | `UserRecommendation` now returns new format of response in `->getData()`, which is a list of `stdClass` instances. + ### Fixed - Exceptions occurring during async request now generate rejected promise (as they should) and are no longer thrown directly. +### Added +- Ability to request item properties in `UserRecommendation` command, which are then returned by Matej alongside with `item_id`. + ## 1.6.0 - 2018-06-01 ### Added - `UserForget` command to anonymize or completely delete all data belonging to specific user(s) (`$matej->request()->forget()`). diff --git a/README.md b/README.md index 6960de7..87cd95d 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ foreach ($response->getCommandResponses() as $commandResponse) { and [Item Properties](#item-properties-setup-to-setup-you-matej-database) endpoints have syntax sugar shortcuts, which makes processing responses easier. See below for detailed examples. -### Item properties setup (to setup you Matej database) +### Item properties setup (to setup your Matej's database) ```php $matej = new Matej('accountId', 'apikey'); @@ -205,8 +205,64 @@ echo $response->getUserMerge()->getStatus(); // SKIPPED echo $response->getRecommendation()->getStatus(); // OK $recommendations = $response->getRecommendation()->getData(); + +// var_dump($recommendations): +// array(2) { +// [0] => object(stdClass)#1 (2) { +// ["item_id"] => string(9) "item_id_1" +// } +// [1] => object(stdClass)#2 (2) { +// ["item_id"] => string(9) "item_id_2" +// } +// } ``` +#### Recommendation response properties + +Every item in Matej has its id, and optionally other item properties. These properties can be set up in [item properties setup](#item-properties-setup-to-setup-you-matej-database), +and you can upload item data in the [events](#send-events-data-to-matej) request. This has major benefit because you can request +these properties to be returned as part of your Recommendation Request. + +We call them response properties, and they can be specified either as the last parameter of `UserRecommendation::create` function, +by calling `->addResponseProperty()` method, or by calling `->setResponseProperties()` method. Following will request an `item_id`, +`item_url` and `item_title`: + +```php +$recommendation = UserRecommendation::create('user-id', 5, 'test-scenario', 1.0, 3600, ['item_url']) + ->addResponseProperty('item_title'); + +$response = $matej->request() + ->recommendation($recommendation) + ->send(); + +$recommendedItems = $response->getRecommendation()->getData(); + +// $recommendedItems is an array of stdClass instances: +// +// array(2) { +// [0] => object(stdClass)#1 (2) { +// ["item_id"] => string(9) "item_id_1" +// ["item_url"] => string(5) "url_1" +// ["item_title"] => string(5) "title_1" +// } +// [1] => object(stdClass)#2 (2) { +// ["item_id"] => string(9) "item_id_2" +// ["item_url"] => string(10) "url_2" +// ["item_title"] => string(10) "title_2" +// } +// } +``` + +If you don't specify any response properties, Matej will return an array of `stdClass` instances, which contain only `item_id` property. +If you do request at least one response property, you don't need to metion `item_id`, as Matej will always return it regardless of the +properties requested. + +If you request an unknown property, Matej will return a `BAD REQUEST` with HTTP status code `400`. + +This way, when you receive recommendations from Matej, you don't need to loop the `item_id` and retrieve further information +from your local database. It means, however, that you'll have to keep all items up to date within Matej, +which can be done through [events](#send-events-data-to-matej) request. + ### Request item sorting for single user Request item sorting for a single user. You can combine this sorting command with the most recent interaction diff --git a/src/Model/Command/UserRecommendation.php b/src/Model/Command/UserRecommendation.php index a3697d5..8bf8303 100644 --- a/src/Model/Command/UserRecommendation.php +++ b/src/Model/Command/UserRecommendation.php @@ -29,18 +29,27 @@ class UserRecommendation extends AbstractCommand implements UserAwareInterface private $hardRotation = false; /** @var string */ private $minimalRelevance = self::MINIMAL_RELEVANCE_LOW; - /** @var array */ + /** @var string[] */ private $filters = ['valid_to >= NOW']; /** @var string|null */ private $modelName = null; + /** @var string[] */ + private $responseProperties = []; - private function __construct(string $userId, int $count, string $scenario, float $rotationRate, int $rotationTime) - { + private function __construct( + string $userId, + int $count, + string $scenario, + float $rotationRate, + int $rotationTime, + array $responseProperties + ) { $this->setUserId($userId); $this->setCount($count); $this->setScenario($scenario); $this->setRotationRate($rotationRate); $this->setRotationTime($rotationTime); + $this->setResponseProperties($responseProperties); } /** @@ -53,6 +62,7 @@ private function __construct(string $userId, int $count, string $scenario, float * Set from 0.0 for no rotation (same items will be recommended) up to 1.0 (same items should not be recommended). * @param int $rotationTime Specify for how long will the item's rotationRate be taken in account and so the item * is penalized for recommendations. + * @param string[] $responseProperties Specify which properties you want to retrieve from Matej alongside the item_id. * @return static */ public static function create( @@ -60,9 +70,10 @@ public static function create( int $count, string $scenario, float $rotationRate, - int $rotationTime + int $rotationTime, + array $responseProperties = [] ): self { - return new static($userId, $count, $scenario, $rotationRate, $rotationTime); + return new static($userId, $count, $scenario, $rotationRate, $rotationTime, $responseProperties); } /** @@ -123,6 +134,33 @@ public function setFilters(array $filters): self return $this; } + /** + * Add another response property you want returned. item_id is always returned by Matej. + */ + public function addResponseProperty(string $property): self + { + Assertion::typeIdentifier($property); + + $this->responseProperties[] = $property; + + return $this; + } + + /** + * Set all response properties you want returned. item_id is always returned by Matej, even when you don't specify it. + * + * @param string[] $properties + * @return $this + */ + public function setResponseProperties(array $properties): self + { + Assertion::allTypeIdentifier($properties); + + $this->responseProperties = $properties; + + return $this; + } + /*** * Set A/B model name * @@ -198,6 +236,7 @@ protected function getCommandParameters(): array 'hard_rotation' => $this->hardRotation, 'min_relevance' => $this->minimalRelevance, 'filter' => $this->assembleFiltersString(), + 'properties' => $this->responseProperties, ]; if ($this->modelName !== null) { diff --git a/tests/integration/RequestBuilder/RecommendationRequestBuilderTest.php b/tests/integration/RequestBuilder/RecommendationRequestBuilderTest.php index a037867..8bf2355 100644 --- a/tests/integration/RequestBuilder/RecommendationRequestBuilderTest.php +++ b/tests/integration/RequestBuilder/RecommendationRequestBuilderTest.php @@ -51,9 +51,28 @@ public function shouldFailOnInvalidModelName(): void $this->expectExceptionCode(400); $this->expectExceptionMessage('BAD REQUEST'); + $recommendation = $this->createRecommendationCommand('user-a') + ->setModelName('invalid-model-name'); + + $this->createMatejInstance() + ->request() + ->recommendation($recommendation) + ->send(); + } + + /** @test */ + public function shouldFailOnInvalidPropertyName(): void + { + $this->expectException(RequestException::class); + $this->expectExceptionCode(400); + $this->expectExceptionMessage('BAD REQUEST'); + + $recommendation = $this->createRecommendationCommand('user-a') + ->addResponseProperty('unknown-property'); + $this->createMatejInstance() ->request() - ->recommendation($this->createRecommendationCommand('user-a')->setModelName('invalid-model-name')) + ->recommendation($recommendation) ->send(); } diff --git a/tests/unit/Http/Fixtures/response-recommendation.json b/tests/unit/Http/Fixtures/response-recommendation.json new file mode 100644 index 0000000..68be37f --- /dev/null +++ b/tests/unit/Http/Fixtures/response-recommendation.json @@ -0,0 +1,38 @@ +{ + "commands": { + "number_of_commands": 3, + "number_of_failed_commands": 0, + "number_of_skipped_commands": 2, + "number_of_successful_commands": 1, + "responses": [ + { + "data": [], + "message": "", + "status": "SKIPPED" + }, + { + "data": [], + "message": "", + "status": "SKIPPED" + }, + { + "data": [ + {"item_id": "jd-5a2ebcb2fc13ae1fdb00016d", "item_url": "http://test-url/5a2ebcb2fc13ae1fdb00016d"}, + {"item_id": "jd-5a2ebcb1fc13ae1fdb0000e3", "item_url": "http://test-url/5a2ebcb1fc13ae1fdb0000e3"}, + {"item_id": "jd-5a2ebcb2fc13ae1fdb000295", "item_url": "http://test-url/5a2ebcb2fc13ae1fdb000295"}, + {"item_id": "jd-5a2ebcb2fc13ae1fdb00022d", "item_url": "http://test-url/5a2ebcb2fc13ae1fdb00022d"}, + {"item_id": "jd-5a2ebcb1fc13ae1fdb00010a", "item_url": "http://test-url/5a2ebcb1fc13ae1fdb00010a"}, + {"item_id": "jd-5a2ebcb1fc13ae1fdb00010d", "item_url": "http://test-url/5a2ebcb1fc13ae1fdb00010d"}, + {"item_id": "jd-5a2ebcb3fc13ae1fdb000389", "item_url": "http://test-url/5a2ebcb3fc13ae1fdb000389"}, + {"item_id": "jd-5a2ebcb3fc13ae1fdb000367", "item_url": "http://test-url/5a2ebcb3fc13ae1fdb000367"}, + {"item_id": "jd-5a2ebcb2fc13ae1fdb000285", "item_url": "http://test-url/5a2ebcb2fc13ae1fdb000285"}, + {"item_id": "jd-5a2ebcb2fc13ae1fdb0002cb", "item_url": "http://test-url/5a2ebcb2fc13ae1fdb0002cb"} + ], + "message": "", + "status": "OK" + } + ] + }, + "message": "", + "status": "OK" +} diff --git a/tests/unit/Http/ResponseDecoderTest.php b/tests/unit/Http/ResponseDecoderTest.php index 7a3c4a2..96d6d16 100644 --- a/tests/unit/Http/ResponseDecoderTest.php +++ b/tests/unit/Http/ResponseDecoderTest.php @@ -6,6 +6,7 @@ use GuzzleHttp\Psr7\Response; use Lmc\Matej\Exception\ResponseDecodingException; use Lmc\Matej\Model\CommandResponse; +use Lmc\Matej\Model\Response\RecommendationsResponse; use Lmc\Matej\UnitTestCase; class ResponseDecoderTest extends UnitTestCase @@ -96,4 +97,22 @@ public function shouldThrowExceptionWhenJsonWithInvalidDataIsDecoded(): void $this->expectExceptionMessage('"invalid": [],'); $this->decoder->decode($response); } + + /** @test */ + public function shouldDecodeRecommendationResponse(): void + { + $response = $this->createJsonResponseFromFile(__DIR__ . '/Fixtures/response-recommendation.json'); + + /** @var RecommendationsResponse */ + $decodedResponse = $this->decoder->decode($response, RecommendationsResponse::class); + + // Recommended items should be stdClasses + $this->assertNotEmpty($decodedResponse->getRecommendation()->getData()); + $this->assertContainsOnlyInstancesOf(\stdClass::class, $decodedResponse->getRecommendation()->getData()); + + foreach ($decodedResponse->getRecommendation()->getData() as $recommendedItem) { + $this->assertObjectHasAttribute('item_id', $recommendedItem); + $this->assertObjectHasAttribute('item_url', $recommendedItem); + } + } } diff --git a/tests/unit/Model/Command/UserRecommendationTest.php b/tests/unit/Model/Command/UserRecommendationTest.php index 4e609ed..dfe475f 100644 --- a/tests/unit/Model/Command/UserRecommendationTest.php +++ b/tests/unit/Model/Command/UserRecommendationTest.php @@ -24,6 +24,7 @@ public function shouldBeInstantiableViaNamedConstructorWithDefaultValues(): void 'hard_rotation' => false, 'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_LOW, 'filter' => 'valid_to >= NOW', + 'properties' => [], // intentionally no model name ==> should be absent when not used ], ], @@ -62,6 +63,7 @@ public function shouldUseCustomParameters(): void 'hard_rotation' => true, 'min_relevance' => UserRecommendation::MINIMAL_RELEVANCE_HIGH, 'filter' => 'foo = bar and baz = ban', + 'properties' => [], 'model_name' => $modelName, ], ], @@ -91,4 +93,20 @@ public function shouldAssembleFilters(): void $this->assertSame('my_filter = 1 and other_filter = foo', $command->jsonSerialize()['parameters']['filter']); } + + /** @test */ + public function shouldAllowModificationOfResponseProperties(): void + { + $command = UserRecommendation::create('user-id', 333, 'test-scenario', 1.0, 3600, ['test']); + $this->assertSame(['test'], $command->jsonSerialize()['parameters']['properties']); + + // Add some properties + $command->addResponseProperty('url'); + + $this->assertSame(['test', 'url'], $command->jsonSerialize()['parameters']['properties']); + + // Overwrite all properties + $command->setResponseProperties(['position_title']); + $this->assertSame(['position_title'], $command->jsonSerialize()['parameters']['properties']); + } }