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", 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/Dataset.php b/src/Dataset.php new file mode 100644 index 0000000..dec74f2 --- /dev/null +++ b/src/Dataset.php @@ -0,0 +1,100 @@ +|null $metadata + * @param array|null $inputSchema + * @param array|null $expectedOutputSchema + * + * @throws JsonException + */ + public function create( + string $name, + ?string $description = null, + ?array $metadata = null, + ?array $inputSchema = null, + ?array $expectedOutputSchema = null, + ): DatasetResponse { + $response = $this->transporter->postJson( + '/api/public/v2/datasets', + array_filter([ + 'name' => $name, + 'description' => $description, + 'metadata' => $metadata, + 'inputSchema' => $inputSchema, + 'expectedOutputSchema' => $expectedOutputSchema, + ], fn ($value) => $value !== null) + ); + + /** @var array{id: string, name: string, projectId: string, createdAt: string, updatedAt: string, description?: string|null, metadata?: array|null, inputSchema?: array|null, expectedOutputSchema?: array|null} $data */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return DatasetResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function get(string $datasetName): DatasetResponse + { + $response = $this->transporter->get( + sprintf('/api/public/v2/datasets/%s', urlencode($datasetName)) + ); + + /** @var array{id: string, name: string, projectId: string, createdAt: string, updatedAt: string, description?: string|null, metadata?: array|null, inputSchema?: array|null, expectedOutputSchema?: array|null} $data */ + $data = json_decode($response->getBody()->getContents(), true, flags: JSON_THROW_ON_ERROR); + + return DatasetResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function list(?int $page = null, ?int $limit = null): DatasetListResponse + { + $response = $this->transporter->get( + '/api/public/v2/datasets', + ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + ], fn ($value) => $value !== null)] + ); + + /** @var array{data: array|null, inputSchema?: array|null, expectedOutputSchema?: array|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 DatasetListResponse::fromArray($data); + } + + /** + * @throws JsonException + */ + public function getRuns(string $datasetName, ?int $page = null, ?int $limit = null): DatasetRunListResponse + { + $response = $this->transporter->get( + sprintf('/api/public/datasets/%s/runs', urlencode($datasetName)), + ['query' => array_filter([ + 'page' => $page, + 'limit' => $limit, + ], fn ($value) => $value !== null)] + ); + + /** @var array{data: array|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 DatasetRunListResponse::fromArray($data); + } +} 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 dataset(): Dataset + { + return new Dataset( + transporter: $this->transporter, + ); + } } diff --git a/src/Responses/DatasetListResponse.php b/src/Responses/DatasetListResponse.php new file mode 100644 index 0000000..716f7f9 --- /dev/null +++ b/src/Responses/DatasetListResponse.php @@ -0,0 +1,35 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array|null, inputSchema?: array|null, expectedOutputSchema?: array|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): DatasetResponse => DatasetResponse::fromArray($item), + $data['data'] + ), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/DatasetResponse.php b/src/Responses/DatasetResponse.php new file mode 100644 index 0000000..199af37 --- /dev/null +++ b/src/Responses/DatasetResponse.php @@ -0,0 +1,53 @@ +|null $metadata + * @param array|null $inputSchema + * @param array|null $expectedOutputSchema + */ + public function __construct( + public string $id, + public string $name, + public string $projectId, + public string $createdAt, + public string $updatedAt, + public ?string $description = null, + public ?array $metadata = null, + public ?array $inputSchema = null, + public ?array $expectedOutputSchema = null, + ) {} + + /** + * @param array{ + * id: string, + * name: string, + * projectId: string, + * createdAt: string, + * updatedAt: string, + * description?: string|null, + * metadata?: array|null, + * inputSchema?: array|null, + * expectedOutputSchema?: array|null + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + name: $data['name'], + projectId: $data['projectId'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + description: $data['description'] ?? null, + metadata: $data['metadata'] ?? null, + inputSchema: $data['inputSchema'] ?? null, + expectedOutputSchema: $data['expectedOutputSchema'] ?? null, + ); + } +} diff --git a/src/Responses/DatasetRunListResponse.php b/src/Responses/DatasetRunListResponse.php new file mode 100644 index 0000000..4e1df57 --- /dev/null +++ b/src/Responses/DatasetRunListResponse.php @@ -0,0 +1,35 @@ + $data + */ + public function __construct( + public array $data, + public MetaData $meta, + ) {} + + /** + * @param array{ + * data: array|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): DatasetRunResponse => DatasetRunResponse::fromArray($item), + $data['data'] + ), + meta: MetaData::fromArray($data['meta']), + ); + } +} diff --git a/src/Responses/DatasetRunResponse.php b/src/Responses/DatasetRunResponse.php new file mode 100644 index 0000000..895f842 --- /dev/null +++ b/src/Responses/DatasetRunResponse.php @@ -0,0 +1,48 @@ +|null $metadata + */ + public function __construct( + public string $id, + public string $name, + public string $datasetId, + public string $datasetName, + public string $createdAt, + public string $updatedAt, + public ?string $description = null, + public ?array $metadata = null, + ) {} + + /** + * @param array{ + * id: string, + * name: string, + * datasetId: string, + * datasetName: string, + * createdAt: string, + * updatedAt: string, + * description?: string|null, + * metadata?: array|null + * } $data + */ + public static function fromArray(array $data): self + { + return new self( + id: $data['id'], + name: $data['name'], + datasetId: $data['datasetId'], + datasetName: $data['datasetName'], + createdAt: $data['createdAt'], + updatedAt: $data['updatedAt'], + description: $data['description'] ?? null, + metadata: $data['metadata'] ?? null, + ); + } +} diff --git a/src/Testing/Responses/GetDatasetListResponse.php b/src/Testing/Responses/GetDatasetListResponse.php new file mode 100644 index 0000000..b105682 --- /dev/null +++ b/src/Testing/Responses/GetDatasetListResponse.php @@ -0,0 +1,59 @@ +|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' => 'dataset-123', + 'name' => 'test-dataset', + 'description' => 'A test dataset', + 'projectId' => 'project-456', + 'createdAt' => '2025-01-15T10:00:00.000Z', + 'updatedAt' => '2025-01-15T10:00:00.000Z', + 'metadata' => ['key' => 'value'], + 'inputSchema' => null, + 'expectedOutputSchema' => null, + ], + [ + 'id' => 'dataset-456', + 'name' => 'another-dataset', + 'description' => 'Another test dataset', + 'projectId' => 'project-456', + 'createdAt' => '2025-01-16T10:00:00.000Z', + 'updatedAt' => '2025-01-16T10:00:00.000Z', + 'metadata' => null, + 'inputSchema' => null, + 'expectedOutputSchema' => null, + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 50, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ], $data); + } +} diff --git a/src/Testing/Responses/GetDatasetResponse.php b/src/Testing/Responses/GetDatasetResponse.php new file mode 100644 index 0000000..e259337 --- /dev/null +++ b/src/Testing/Responses/GetDatasetResponse.php @@ -0,0 +1,38 @@ +|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' => 'dataset-123', + 'name' => 'test-dataset', + 'description' => 'A test dataset', + 'projectId' => 'project-456', + 'createdAt' => '2025-01-15T10:00:00.000Z', + 'updatedAt' => '2025-01-15T10:00:00.000Z', + 'metadata' => ['key' => 'value'], + 'inputSchema' => null, + 'expectedOutputSchema' => null, + ], $data); + } +} diff --git a/src/Testing/Responses/GetDatasetRunListResponse.php b/src/Testing/Responses/GetDatasetRunListResponse.php new file mode 100644 index 0000000..9ff46d1 --- /dev/null +++ b/src/Testing/Responses/GetDatasetRunListResponse.php @@ -0,0 +1,57 @@ +|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' => 'run-123', + 'name' => 'test-run', + 'description' => 'A test run', + 'datasetId' => 'dataset-123', + 'datasetName' => 'test-dataset', + 'createdAt' => '2025-01-15T10:00:00.000Z', + 'updatedAt' => '2025-01-15T10:00:00.000Z', + 'metadata' => ['key' => 'value'], + ], + [ + 'id' => 'run-456', + 'name' => 'another-run', + 'description' => null, + 'datasetId' => 'dataset-123', + 'datasetName' => 'test-dataset', + 'createdAt' => '2025-01-16T10:00:00.000Z', + 'updatedAt' => '2025-01-16T10:00:00.000Z', + 'metadata' => null, + ], + ], + 'meta' => [ + 'page' => 1, + 'limit' => 50, + 'totalPages' => 1, + 'totalItems' => 2, + ], + ], $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/DatasetTest.php b/tests/Feature/DatasetTest.php new file mode 100644 index 0000000..6457e4b --- /dev/null +++ b/tests/Feature/DatasetTest.php @@ -0,0 +1,179 @@ + $handlerStack]); + + $dataset = (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->create( + name: 'test-dataset', + description: 'A test dataset', + metadata: ['key' => 'value'], + ); + + expect($dataset)->toBeInstanceOf(DatasetResponse::class) + ->and($dataset->id)->toBe('dataset-123') + ->and($dataset->name)->toBe('test-dataset') + ->and($dataset->description)->toBe('A test dataset') + ->and($dataset->projectId)->toBe('project-456') + ->and($dataset->metadata)->toBe(['key' => 'value']); +}); + +it('can get a dataset by name', function (): void { + $mock = new MockHandler([ + new GetDatasetResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $dataset = (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->get('test-dataset'); + + expect($dataset)->toBeInstanceOf(DatasetResponse::class) + ->and($dataset->id)->toBe('dataset-123') + ->and($dataset->name)->toBe('test-dataset'); +}); + +it('can list datasets', function (): void { + $mock = new MockHandler([ + new GetDatasetListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $datasets = (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->list(); + + expect($datasets)->toBeInstanceOf(DatasetListResponse::class) + ->and($datasets->data)->toBeArray() + ->and($datasets->data)->toHaveCount(2) + ->and($datasets->data[0])->toBeInstanceOf(DatasetResponse::class) + ->and($datasets->data[0]->name)->toBe('test-dataset') + ->and($datasets->data[1]->name)->toBe('another-dataset') + ->and($datasets->meta)->toBeInstanceOf(MetaData::class) + ->and($datasets->meta->totalItems)->toBe(2); +}); + +it('can list datasets with pagination', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new GetDatasetListResponse, + ]); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client(['handler' => $stack]); + + (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->list(page: 2, 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('page=2') + ->and($query)->toContain('limit=10'); +}); + +it('can get dataset runs', function (): void { + $mock = new MockHandler([ + new GetDatasetRunListResponse, + ]); + + $handlerStack = HandlerStack::create($mock); + $client = new Client(['handler' => $handlerStack]); + + $runs = (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->getRuns('test-dataset'); + + expect($runs)->toBeInstanceOf(DatasetRunListResponse::class) + ->and($runs->data)->toBeArray() + ->and($runs->data)->toHaveCount(2) + ->and($runs->data[0])->toBeInstanceOf(DatasetRunResponse::class) + ->and($runs->data[0]->name)->toBe('test-run') + ->and($runs->data[0]->datasetId)->toBe('dataset-123') + ->and($runs->data[0]->datasetName)->toBe('test-dataset') + ->and($runs->data[1]->name)->toBe('another-run') + ->and($runs->meta)->toBeInstanceOf(MetaData::class) + ->and($runs->meta->totalItems)->toBe(2); +}); + +it('sends correct payload when creating a dataset', function (): void { + /** @var array $history */ + $history = []; + + $mock = new MockHandler([ + new GetDatasetResponse, + ]); + + $stack = HandlerStack::create($mock); + $stack->push(Middleware::history($history)); + + $client = new Client(['handler' => $stack]); + + (new Langfuse(new HttpTransporter($client))) + ->dataset() + ->create( + name: 'my-dataset', + description: 'Test description', + metadata: ['env' => 'test'], + inputSchema: ['type' => 'object'], + expectedOutputSchema: ['type' => 'string'], + ); + + 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/v2/datasets') + ->and($request->getHeaderLine('Content-Type'))->toContain('application/json'); + + /** @var array $body */ + $body = json_decode((string) $request->getBody(), true); + + expect($body['name'])->toBe('my-dataset') + ->and($body['description'])->toBe('Test description') + ->and($body['metadata'])->toBe(['env' => 'test']) + ->and($body['inputSchema'])->toBe(['type' => 'object']) + ->and($body['expectedOutputSchema'])->toBe(['type' => 'string']); +});