Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .release-please-manifest.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
".": "0.6.2"
".": "0.7.0"
}
4 changes: 2 additions & 2 deletions .stats.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
configured_endpoints: 21
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-d9763d006969b49a1473851069fdfa429eb13133b64103a62963bb70ddb22305.yml
openapi_spec_hash: 6aee689b7a759b12c85c088c15e29bc0
openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/cas-parser%2Fcas-parser-e5c0c65637cdf3a6c4360b8193973b73a3d35ad1056ef607c3319ef03e591a55.yml
openapi_spec_hash: 7515d1e5fe3130b9f5411f7aacbc8a64
config_hash: 5509bb7a961ae2e79114b24c381606d4
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,21 @@
# Changelog

## 0.7.0 (2026-04-19)

Full Changelog: [v0.6.2...v0.7.0](https://github.com/CASParser/cas-parser-php/compare/v0.6.2...v0.7.0)

### Features

* **api:** api update ([5550179](https://github.com/CASParser/cas-parser-php/commit/5550179c5e002c9d2e91e912b8fee39e4af803fd))
* **api:** api update ([2892c8f](https://github.com/CASParser/cas-parser-php/commit/2892c8f1f0d9cc800e8e03e9899c7f7cef3185c3))


### Bug Fixes

* **client:** properly generate file params ([59582b0](https://github.com/CASParser/cas-parser-php/commit/59582b0bcdd8be588e3f53f062a2ff16d29df00d))
* **client:** resolve serialization issue with unions and enums ([5257135](https://github.com/CASParser/cas-parser-php/commit/5257135e3688cfa7d602bcf7211c1fef01181b59))
* populate enum-typed properties with enum instances ([4fe2734](https://github.com/CASParser/cas-parser-php/commit/4fe2734cd1aa30730c5192cb8a1e20e0a5b0a5a5))

## 0.6.2 (2026-03-17)

Full Changelog: [v0.6.1...v0.6.2](https://github.com/CASParser/cas-parser-php/compare/v0.6.1...v0.6.2)
Expand Down
17 changes: 1 addition & 16 deletions src/Core/Attributes/Required.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,6 @@ class Required

public readonly bool $nullable;

/** @var array<string,Converter> */
private static array $enumConverters = [];

/**
* @param class-string<ConverterSource>|Converter|string|null $type
* @param class-string<\BackedEnum>|Converter|null $enum
Expand All @@ -52,24 +49,12 @@ public function __construct(
$type ??= new MapOf($map);
}
if (null !== $enum) {
$type ??= $enum instanceof Converter ? $enum : self::enumConverter($enum);
$type ??= $enum instanceof Converter ? $enum : EnumOf::fromBackedEnum($enum);
}

$this->apiName = $apiName;
$this->type = $type;
$this->optional = false;
$this->nullable = $nullable;
}

/** @property class-string<\BackedEnum> $enum */
private static function enumConverter(string $enum): Converter
{
if (!isset(self::$enumConverters[$enum])) {
// @phpstan-ignore-next-line argument.type
$converter = new EnumOf(array_column($enum::cases(), column_key: 'value'));
self::$enumConverters[$enum] = $converter;
}

return self::$enumConverters[$enum];
}
}
19 changes: 19 additions & 0 deletions src/Core/Conversion.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use CasParser\Core\Conversion\Contracts\Converter;
use CasParser\Core\Conversion\Contracts\ConverterSource;
use CasParser\Core\Conversion\DumpState;
use CasParser\Core\Conversion\EnumOf;

/**
* @internal
Expand All @@ -21,6 +22,10 @@ public static function dump_unknown(mixed $value, DumpState $state): mixed
}

if (is_object($value)) {
if ($value instanceof FileParam) {
return $value;
}

if (is_a($value, class: ConverterSource::class)) {
return $value::converter()->dump($value, state: $state);
}
Expand Down Expand Up @@ -61,6 +66,13 @@ public static function coerce(Converter|ConverterSource|string $target, mixed $v
return $target->coerce($value, state: $state);
}

// BackedEnum class-name targets: wrap in EnumOf so enum values are scored
// against the enum's cases. Without this, tryConvert's default case scores
// any class-name target as `no`, even when the value is a valid enum member.
if (is_a($target, class: \BackedEnum::class, allow_string: true)) {
return EnumOf::fromBackedEnum($target)->coerce($value, state: $state);
}

return self::tryConvert($target, value: $value, state: $state);
}

Expand All @@ -74,6 +86,13 @@ public static function dump(Converter|ConverterSource|string $target, mixed $val
return $target::converter()->dump($value, state: $state);
}

// BackedEnum class-name targets: wrap in EnumOf so enum values are scored
// against the enum's cases. Without this, tryConvert's default case scores
// any class-name target as `no`, even when the value is a valid enum member.
if (is_a($target, class: \BackedEnum::class, allow_string: true)) {
return EnumOf::fromBackedEnum($target)->dump($value, state: $state);
}

self::tryConvert($target, value: $value, state: $state);

return self::dump_unknown($value, state: $state);
Expand Down
33 changes: 29 additions & 4 deletions src/Core/Conversion/EnumOf.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,22 +14,46 @@ final class EnumOf implements Converter
{
private readonly string $type;

/** @var array<class-string<\BackedEnum>, self> */
private static array $cache = [];

/**
* @param list<bool|float|int|string|null> $members
* @param class-string<\BackedEnum>|null $class
*/
public function __construct(private readonly array $members)
{
public function __construct(
private readonly array $members,
private readonly ?string $class = null,
) {
$type = 'NULL';
foreach ($this->members as $member) {
$type = gettype($member);
}
$this->type = $type;
}

/** @param class-string<\BackedEnum> $enum */
public static function fromBackedEnum(string $enum): self
{
// @phpstan-ignore-next-line argument.type
return self::$cache[$enum] ??= new self(
array_column($enum::cases(), column_key: 'value'),
class: $enum,
);
}

public function coerce(mixed $value, CoerceState $state): mixed
{
$this->tally($value, state: $state);

if ($value instanceof \BackedEnum) {
return $value;
}

if (null !== $this->class && (is_int($value) || is_string($value))) {
return ($this->class)::tryFrom($value) ?? $value;
}

return $value;
}

Expand All @@ -42,9 +66,10 @@ public function dump(mixed $value, DumpState $state): mixed

private function tally(mixed $value, CoerceState|DumpState $state): void
{
if (in_array($value, haystack: $this->members, strict: true)) {
$needle = $value instanceof \BackedEnum ? $value->value : $value;
if (in_array($needle, haystack: $this->members, strict: true)) {
++$state->yes;
} elseif ($this->type === gettype($value)) {
} elseif ($this->type === gettype($needle)) {
++$state->maybe;
} else {
++$state->no;
Expand Down
63 changes: 63 additions & 0 deletions src/Core/FileParam.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

declare(strict_types=1);

namespace CasParser\Core;

/**
* Represents a file to upload in a multipart request.
*
* ```php
* // From a file on disk:
* $client->files->upload(file: FileParam::fromResource(fopen('data.csv', 'r')));
*
* // From a string:
* $client->files->upload(file: FileParam::fromString('csv data...', 'data.csv'));
* ```
*/
final class FileParam
{
public const DEFAULT_CONTENT_TYPE = 'application/octet-stream';

/**
* @param resource|string $data the file content as a resource or string
*/
private function __construct(
public readonly mixed $data,
public readonly string $filename,
public readonly string $contentType = self::DEFAULT_CONTENT_TYPE,
) {}

/**
* Create a FileParam from an open resource (e.g. from fopen()).
*
* @param resource $resource an open file resource
* @param string|null $filename Override the filename. Defaults to the resource URI basename.
* @param string $contentType override the content type
*/
public static function fromResource(mixed $resource, ?string $filename = null, string $contentType = self::DEFAULT_CONTENT_TYPE): self
{
if (!is_resource($resource)) {
throw new \InvalidArgumentException('Expected a resource, got '.get_debug_type($resource));
}

if (null === $filename) {
$meta = stream_get_meta_data($resource);
$filename = basename($meta['uri'] ?? 'upload');
}

return new self($resource, filename: $filename, contentType: $contentType);
}

/**
* Create a FileParam from a string.
*
* @param string $content the file content
* @param string $filename the filename for the Content-Disposition header
* @param string $contentType override the content type
*/
public static function fromString(string $content, string $filename, string $contentType = self::DEFAULT_CONTENT_TYPE): self
{
return new self($content, filename: $filename, contentType: $contentType);
}
}
60 changes: 47 additions & 13 deletions src/Core/Util.php
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@ public static function withSetBody(

if (preg_match('/^multipart\/form-data/', $contentType)) {
[$boundary, $gen] = self::encodeMultipartStreaming($body);
$encoded = implode('', iterator_to_array($gen));
$encoded = implode('', iterator_to_array($gen, preserve_keys: false));
$stream = $factory->createStream($encoded);

/** @var RequestInterface */
Expand Down Expand Up @@ -447,11 +447,18 @@ private static function writeMultipartContent(
): \Generator {
$contentLine = "Content-Type: %s\r\n\r\n";

if (is_resource($val)) {
yield sprintf($contentLine, $contentType ?? 'application/octet-stream');
while (!feof($val)) {
if ($read = fread($val, length: self::BUF_SIZE)) {
yield $read;
if ($val instanceof FileParam) {
$ct = $val->contentType ?? $contentType;

yield sprintf($contentLine, $ct);
$data = $val->data;
if (is_string($data)) {
yield $data;
} else { // resource
while (!feof($data)) {
if ($read = fread($data, length: self::BUF_SIZE)) {
yield $read;
}
}
}
} elseif (is_string($val) || is_numeric($val) || is_bool($val)) {
Expand Down Expand Up @@ -483,17 +490,48 @@ private static function writeMultipartChunk(
yield 'Content-Disposition: form-data';

if (!is_null($key)) {
$name = rawurlencode(self::strVal($key));
$name = str_replace(['"', "\r", "\n"], replace: '', subject: $key);

yield "; name=\"{$name}\"";
}

// File uploads require a filename in the Content-Disposition header,
// e.g. `Content-Disposition: form-data; name="file"; filename="data.csv"`
// Without this, many servers will reject the upload with a 400.
if ($val instanceof FileParam) {
$filename = str_replace(['"', "\r", "\n"], replace: '', subject: $val->filename);

yield "; filename=\"{$filename}\"";
}

yield "\r\n";
foreach (self::writeMultipartContent($val, closing: $closing) as $chunk) {
yield $chunk;
}
}

/**
* Expands list arrays into separate multipart parts, applying the configured array key format.
*
* @param list<callable> $closing
*
* @return \Generator<string>
*/
private static function writeMultipartField(
string $boundary,
?string $key,
mixed $val,
array &$closing
): \Generator {
if (is_array($val) && array_is_list($val)) {
foreach ($val as $item) {
yield from self::writeMultipartField(boundary: $boundary, key: $key, val: $item, closing: $closing);
}
} else {
yield from self::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing);
}
}

/**
* @param bool|int|float|string|resource|\Traversable<mixed,>|array<string,mixed>|null $body
*
Expand All @@ -508,14 +546,10 @@ private static function encodeMultipartStreaming(mixed $body): array
try {
if (is_array($body) || is_object($body)) {
foreach ((array) $body as $key => $val) {
foreach (static::writeMultipartChunk(boundary: $boundary, key: $key, val: $val, closing: $closing) as $chunk) {
yield $chunk;
}
yield from static::writeMultipartField(boundary: $boundary, key: $key, val: $val, closing: $closing);
}
} else {
foreach (static::writeMultipartChunk(boundary: $boundary, key: null, val: $body, closing: $closing) as $chunk) {
yield $chunk;
}
yield from static::writeMultipartField(boundary: $boundary, key: null, val: $body, closing: $closing);
}

yield "--{$boundary}--\r\n";
Expand Down
Loading