From 06b63e7f13901cd9bc90bbcf7387a81bb14ecfc4 Mon Sep 17 00:00:00 2001 From: Tycho Engberink Date: Thu, 22 Jan 2026 13:15:53 +0100 Subject: [PATCH 1/2] feat: add DatasetItem resource for dataset item management Implements Dataset Item API endpoints for creating, retrieving, listing, and deleting dataset items: - DatasetStatus enum (ACTIVE, ARCHIVED) - DatasetItemResponse and DatasetItemListResponse value objects - DatasetItem resource class with create(), get(), list(), delete() methods - Adds delete() method to TransporterInterface - Test fixtures and 6 feature tests with full coverage --- src/Contracts/TransporterInterface.php | 16 +- src/DatasetItem.php | 100 +++++++++ src/Enums/DatasetStatus.php | 11 + src/Langfuse.php | 10 +- src/Responses/DatasetItemListResponse.php | 35 ++++ src/Responses/DatasetItemResponse.php | 59 ++++++ .../Responses/GetDatasetItemListResponse.php | 63 ++++++ .../Responses/GetDatasetItemResponse.php | 40 ++++ src/Transporters/HttpTransporter.php | 13 +- tests/Feature/DatasetItemTest.php | 198 ++++++++++++++++++ 10 files changed, 528 insertions(+), 17 deletions(-) create mode 100644 src/DatasetItem.php create mode 100644 src/Enums/DatasetStatus.php create mode 100644 src/Responses/DatasetItemListResponse.php create mode 100644 src/Responses/DatasetItemResponse.php create mode 100644 src/Testing/Responses/GetDatasetItemListResponse.php create mode 100644 src/Testing/Responses/GetDatasetItemResponse.php create mode 100644 tests/Feature/DatasetItemTest.php diff --git a/src/Contracts/TransporterInterface.php b/src/Contracts/TransporterInterface.php index 8234ade..558497a 100644 --- a/src/Contracts/TransporterInterface.php +++ b/src/Contracts/TransporterInterface.php @@ -9,34 +9,34 @@ interface TransporterInterface { /** - * @param array $options + * @param array $options */ public function request(string $method, string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function get(string $uri, array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function post(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function postJson(string $uri, array $data = [], array $options = []): ResponseInterface; /** - * @param array $options + * @param array $options */ public function delete(string $uri, array $options = []): ResponseInterface; /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options */ public function patchJson(string $uri, array $data = [], array $options = []): ResponseInterface; } diff --git a/src/DatasetItem.php b/src/DatasetItem.php new file mode 100644 index 0000000..8439607 --- /dev/null +++ b/src/DatasetItem.php @@ -0,0 +1,100 @@ +|null $metadata + * + * @throws JsonException + */ + public function create( + string $datasetName, + mixed $input = null, + mixed $expectedOutput = null, + ?array $metadata = null, + ?string $sourceTraceId = null, + ?string $sourceObservationId = null, + ?string $id = null, + ?DatasetStatus $status = null, + ): DatasetItemResponse { + $response = $this->transporter->postJson( + '/api/public/dataset-items', + array_filter([ + 'datasetName' => $datasetName, + 'input' => $input, + 'expectedOutput' => $expectedOutput, + 'metadata' => $metadata, + 'sourceTraceId' => $sourceTraceId, + 'sourceObservationId' => $sourceObservationId, + 'id' => $id, + 'status' => $status?->value, + ], fn ($value) => $value !== null) + ); + + /** @var array{id: string, status: string, datasetId: string, datasetName: string, createdAt: string, updatedAt: string, input?: mixed, expectedOutput?: mixed, metadata?: array|null, sourceTraceId?: string|null, sourceObservationId?: string|null} $data */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return DatasetItemResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function get(string $id): DatasetItemResponse + { + $response = $this->transporter->get( + sprintf('/api/public/dataset-items/%s', urlencode($id)) + ); + + /** @var array{id: string, status: string, datasetId: string, datasetName: string, createdAt: string, updatedAt: string, input?: mixed, expectedOutput?: mixed, metadata?: array|null, sourceTraceId?: string|null, sourceObservationId?: string|null} $data */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return DatasetItemResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function list( + ?string $datasetName = null, + ?string $sourceTraceId = null, + ?string $sourceObservationId = null, + ?int $page = null, + ?int $limit = null, + ): DatasetItemListResponse { + $response = $this->transporter->get( + '/api/public/dataset-items', + ['query' => array_filter([ + 'datasetName' => $datasetName, + 'sourceTraceId' => $sourceTraceId, + 'sourceObservationId' => $sourceObservationId, + 'page' => $page, + 'limit' => $limit, + ], fn ($value) => $value !== null)] + ); + + /** @var array{data: array|null, sourceTraceId?: string|null, sourceObservationId?: string|null}>, meta: array{page: int, limit: int, totalPages: int, totalItems: int}} $data */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return DatasetItemListResponse::fromArray($data); + } + + public function delete(string $id): void + { + $this->transporter->delete( + sprintf('/api/public/dataset-items/%s', urlencode($id)) + ); + } +} diff --git a/src/Enums/DatasetStatus.php b/src/Enums/DatasetStatus.php new file mode 100644 index 0000000..2552c0b --- /dev/null +++ b/src/Enums/DatasetStatus.php @@ -0,0 +1,11 @@ +transporter, ); } + + public function datasetItem(): DatasetItem + { + return new DatasetItem( + transporter: $this->transporter, + ); + } } diff --git a/src/Responses/DatasetItemListResponse.php b/src/Responses/DatasetItemListResponse.php new file mode 100644 index 0000000..db64f74 --- /dev/null +++ b/src/Responses/DatasetItemListResponse.php @@ -0,0 +1,35 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array|null, sourceTraceId?: string|null, sourceObservationId?: string|null}>, + * meta: array{page: int, limit: int, totalPages: int, totalItems: int} + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + data: array_map( + fn (array $item): DatasetItemResponse => DatasetItemResponse::fromArray($item), + $data['data'] + ), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/DatasetItemResponse.php b/src/Responses/DatasetItemResponse.php new file mode 100644 index 0000000..7984f34 --- /dev/null +++ b/src/Responses/DatasetItemResponse.php @@ -0,0 +1,59 @@ +|null $metadata + */ + public function __construct( + public string $id, + public DatasetStatus $status, + public string $datasetId, + public string $datasetName, + public string $createdAt, + public string $updatedAt, + public mixed $input = null, + public mixed $expectedOutput = null, + public ?array $metadata = null, + public ?string $sourceTraceId = null, + public ?string $sourceObservationId = null, + ) {} + + /** + * @param array{ + * id: string, + * status: string, + * datasetId: string, + * datasetName: string, + * createdAt: string, + * updatedAt: string, + * input?: mixed, + * expectedOutput?: mixed, + * metadata?: array|null, + * sourceTraceId?: string|null, + * sourceObservationId?: string|null + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + status: DatasetStatus::from($data['status']), + datasetId: $data['datasetId'], + datasetName: $data['datasetName'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + input: $data['input'] ?? null, + expectedOutput: $data['expectedOutput'] ?? null, + metadata: $data['metadata'] ?? null, + sourceTraceId: $data['sourceTraceId'] ?? null, + sourceObservationId: $data['sourceObservationId'] ?? null, + ); + } +} diff --git a/src/Testing/Responses/GetDatasetItemListResponse.php b/src/Testing/Responses/GetDatasetItemListResponse.php new file mode 100644 index 0000000..9d1145c --- /dev/null +++ b/src/Testing/Responses/GetDatasetItemListResponse.php @@ -0,0 +1,63 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'data' => [ + [ + 'id' => 'item-123', + 'status' => 'ACTIVE', + 'datasetId' => 'dataset-456', + 'datasetName' => 'test-dataset', + 'createdAt' => '2025-01-15T10:00:00.000Z', + 'updatedAt' => '2025-01-15T10:00:00.000Z', + 'input' => ['prompt' => 'What is AI?'], + 'expectedOutput' => ['response' => 'AI is artificial intelligence'], + 'metadata' => ['key' => 'value'], + 'sourceTraceId' => null, + 'sourceObservationId' => null, + ], + [ + 'id' => 'item-456', + 'status' => 'ACTIVE', + 'datasetId' => 'dataset-456', + 'datasetName' => 'test-dataset', + 'createdAt' => '2025-01-16T10:00:00.000Z', + 'updatedAt' => '2025-01-16T10:00:00.000Z', + 'input' => ['prompt' => 'What is ML?'], + 'expectedOutput' => ['response' => 'ML is machine learning'], + 'metadata' => null, + 'sourceTraceId' => 'trace-789', + 'sourceObservationId' => null, + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 50, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ], $data); + } +} diff --git a/src/Testing/Responses/GetDatasetItemResponse.php b/src/Testing/Responses/GetDatasetItemResponse.php new file mode 100644 index 0000000..5dda123 --- /dev/null +++ b/src/Testing/Responses/GetDatasetItemResponse.php @@ -0,0 +1,40 @@ +|string> $headers + * @param array $data + */ + public function __construct(int $status = 200, array $headers = [], string $version = '1.1', ?string $reason = null, array $data = []) + { + parent::__construct($status, $headers, (string) json_encode($this->payload($data)), $version, $reason); + } + + /** + * @param array $data + * @return array + */ + public function payload(array $data = []): array + { + return array_merge([ + 'id' => 'item-123', + 'status' => 'ACTIVE', + 'datasetId' => 'dataset-456', + 'datasetName' => 'test-dataset', + 'createdAt' => '2025-01-15T10:00:00.000Z', + 'updatedAt' => '2025-01-15T10:00:00.000Z', + 'input' => ['prompt' => 'What is AI?'], + 'expectedOutput' => ['response' => 'AI is artificial intelligence'], + 'metadata' => ['key' => 'value'], + 'sourceTraceId' => null, + 'sourceObservationId' => null, + ], $data); + } +} diff --git a/src/Transporters/HttpTransporter.php b/src/Transporters/HttpTransporter.php index 319cf49..a2b734b 100644 --- a/src/Transporters/HttpTransporter.php +++ b/src/Transporters/HttpTransporter.php @@ -21,8 +21,7 @@ class HttpTransporter implements TransporterInterface { public function __construct( public readonly ClientInterface $client - ) { - } + ) {} /** * @throws BadRequestException @@ -45,7 +44,7 @@ public function request(string $method, string $uri, array $options = []): Respo } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -61,7 +60,7 @@ public function get(string $uri, array $options = []): ResponseInterface } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -82,7 +81,7 @@ public function postJson(string $uri, array $data = [], array $options = []): Re } /** - * @param array $options + * @param array $options * * @throws BadRequestException * @throws ForbiddenException @@ -98,8 +97,8 @@ public function delete(string $uri, array $options = []): ResponseInterface } /** - * @param array $data - * @param array $options + * @param array $data + * @param array $options * * @throws BadRequestException * @throws ForbiddenException diff --git a/tests/Feature/DatasetItemTest.php b/tests/Feature/DatasetItemTest.php new file mode 100644 index 0000000..4a23aad --- /dev/null +++ b/tests/Feature/DatasetItemTest.php @@ -0,0 +1,198 @@ + $handlerStack]); + + $item = (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->create( + datasetName: 'test-dataset', + input: ['prompt' => 'What is AI?'], + expectedOutput: ['response' => 'AI is artificial intelligence'], + metadata: ['key' => 'value'], + ); + + expect($item)->toBeInstanceOf(DatasetItemResponse::class) + ->and($item->id)->toBe('item-123') + ->and($item->status)->toBe(DatasetStatus::ACTIVE) + ->and($item->datasetId)->toBe('dataset-456') + ->and($item->datasetName)->toBe('test-dataset') + ->and($item->input)->toBe(['prompt' => 'What is AI?']) + ->and($item->expectedOutput)->toBe(['response' => 'AI is artificial intelligence']) + ->and($item->metadata)->toBe(['key' => 'value']); +}); + +it('can get a dataset item by id', function (): void { + $mock = new MockHandler([ + new GetDatasetItemResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $item = (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->get('item-123'); + + expect($item)->toBeInstanceOf(DatasetItemResponse::class) + ->and($item->id)->toBe('item-123') + ->and($item->datasetName)->toBe('test-dataset'); +}); + +it('can list dataset items', function (): void { + $mock = new MockHandler([ + new GetDatasetItemListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $items = (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->list(); + + expect($items)->toBeInstanceOf(DatasetItemListResponse::class) + ->and($items->data)->toBeArray() + ->and($items->data)->toHaveCount(2) + ->and($items->data[0])->toBeInstanceOf(DatasetItemResponse::class) + ->and($items->data[0]->id)->toBe('item-123') + ->and($items->data[1]->id)->toBe('item-456') + ->and($items->meta)->toBeInstanceOf(MetaData::class) + ->and($items->meta->totalItems)->toBe(2); +}); + +it('can list dataset items with filters', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new GetDatasetItemListResponse, + ]); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client(['handler' => $stack]); + + (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->list( + datasetName: 'test-dataset', + sourceTraceId: 'trace-123', + page: 1, + limit: 10, + ); + + expect($history)->toHaveCount(1); + + /** @var array{request: RequestInterface} $first */ + $first = $history[0]; + /** @var RequestInterface $request */ + $request = $first['request']; + $query = $request->getUri()->getQuery(); + + expect($query)->toContain('datasetName=test-dataset') + ->and($query)->toContain('sourceTraceId=trace-123') + ->and($query)->toContain('page=1') + ->and($query)->toContain('limit=10'); +}); + +it('can delete a dataset item', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new Response(204), + ]); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client(['handler' => $stack]); + + (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->delete('item-123'); + + expect($history)->toHaveCount(1); + + /** @var array{request: RequestInterface} $first */ + $first = $history[0]; + /** @var RequestInterface $request */ + $request = $first['request']; + + expect($request->getMethod())->toBe('DELETE') + ->and((string) $request->getUri())->toContain('/api/public/dataset-items/item-123'); +}); + +it('sends correct payload when creating a dataset item', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new GetDatasetItemResponse, + ]); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client(['handler' => $stack]); + + (new Langfuse(new HttpTransporter($client))) + ->datasetItem() + ->create( + datasetName: 'my-dataset', + input: ['question' => 'test'], + expectedOutput: ['answer' => 'response'], + metadata: ['env' => 'test'], + sourceTraceId: 'trace-789', + sourceObservationId: 'obs-123', + id: 'custom-item-id', + status: DatasetStatus::ACTIVE, + ); + + expect($history)->toHaveCount(1); + + /** @var array{request: RequestInterface} $first */ + $first = $history[0]; + /** @var RequestInterface $request */ + $request = $first['request']; + + expect($request->getMethod())->toBe('POST') + ->and((string) $request->getUri())->toContain('/api/public/dataset-items') + ->and($request->getHeaderLine('Content-Type'))->toContain('application/json'); + + /** @var array $body */ + $body = json_decode((string) $request->getBody(), true); + + expect($body['datasetName'])->toBe('my-dataset') + ->and($body['input'])->toBe(['question' => 'test']) + ->and($body['expectedOutput'])->toBe(['answer' => 'response']) + ->and($body['metadata'])->toBe(['env' => 'test']) + ->and($body['sourceTraceId'])->toBe('trace-789') + ->and($body['sourceObservationId'])->toBe('obs-123') + ->and($body['id'])->toBe('custom-item-id') + ->and($body['status'])->toBe('ACTIVE'); +}); From 7a60651ef3a0f9b3163e3ab80d76aa9be947dfd9 Mon Sep 17 00:00:00 2001 From: Tycho Engberink Date: Fri, 20 Feb 2026 13:37:19 +0100 Subject: [PATCH 2/2] chore: remove test:refactor dry-run --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index 9651b02..98892f9 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "test:lint": "@php vendor/bin/pint --config https://raw.githubusercontent.com/DIJ-digital/pint-config/main/pint.json", "test:unit": "pest", "test:types": "phpstan", - "test:refactor": "rector --dry-run", + "test:refactor": "rector", "test": [ "@test:lint", "@test:type-coverage",