From ba81176cd97fd9cdd01cc9949a992376adaa09f9 Mon Sep 17 00:00:00 2001 From: Loic Gouttefangeas Date: Fri, 6 Mar 2026 14:59:24 +0100 Subject: [PATCH 1/4] build(ci): Add SDK preview on SDK generator update --- .github/workflows/preview.yaml | 148 +++++++++++++++ package.json | 2 +- .../rebilly-php.config.ts | 4 +- .../templates/file-header.handlebars | 10 + ...el-factory-ConfigurablePlan.php.handlebars | 17 ++ .../model-factory-Plan.php.handlebars | 20 ++ .../operation-json-collection.php.handlebars | 16 ++ .../operation-paginator.php.handlebars | 17 ++ .../templates/operation-pdf.php.handlebars | 5 + .../templates/static/gitignore.handlebars | 7 + .../static/php-cs-fixer.php.handlebars | 116 ++++++++++++ .../templates/static/psalm.xml.handlebars | 36 ++++ .../static/src/Client.php.handlebars | 172 ++++++++++++++++++ .../static/src/CombinedService.php.handlebars | 12 ++ .../DataValidationException.php.handlebars | 26 +++ .../ApiKeyAuthentication.php.handlebars | 24 +++ .../src/Middleware/BaseUri.php.handlebars | 71 ++++++++ .../Middleware/ErrorHandler.php.handlebars | 57 ++++++ .../src/Middleware/UserAgent.php.handlebars | 41 +++++ 19 files changed, 798 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/preview.yaml rename rebilly-php.config.ts => sdk-generator/rebilly-php.config.ts (99%) create mode 100644 sdk-generator/templates/file-header.handlebars create mode 100644 sdk-generator/templates/model-factory-ConfigurablePlan.php.handlebars create mode 100644 sdk-generator/templates/model-factory-Plan.php.handlebars create mode 100644 sdk-generator/templates/operation-json-collection.php.handlebars create mode 100644 sdk-generator/templates/operation-paginator.php.handlebars create mode 100644 sdk-generator/templates/operation-pdf.php.handlebars create mode 100644 sdk-generator/templates/static/gitignore.handlebars create mode 100644 sdk-generator/templates/static/php-cs-fixer.php.handlebars create mode 100644 sdk-generator/templates/static/psalm.xml.handlebars create mode 100644 sdk-generator/templates/static/src/Client.php.handlebars create mode 100644 sdk-generator/templates/static/src/CombinedService.php.handlebars create mode 100644 sdk-generator/templates/static/src/Exception/DataValidationException.php.handlebars create mode 100644 sdk-generator/templates/static/src/Middleware/ApiKeyAuthentication.php.handlebars create mode 100644 sdk-generator/templates/static/src/Middleware/BaseUri.php.handlebars create mode 100644 sdk-generator/templates/static/src/Middleware/ErrorHandler.php.handlebars create mode 100644 sdk-generator/templates/static/src/Middleware/UserAgent.php.handlebars diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml new file mode 100644 index 000000000..5b136d39e --- /dev/null +++ b/.github/workflows/preview.yaml @@ -0,0 +1,148 @@ +name: Preview generated SDK vs main + +on: + pull_request: + paths: + - "sdk-generator/**" + - "package-lock.json" + workflow_dispatch: + +jobs: + generate-and-diff: + name: Generate SDK and diff vs main + runs-on: ubuntu-latest + + steps: + - name: Checkout current ref + uses: actions/checkout@v4 + with: + token: ${{ secrets.MACHINE_USER_PAT }} + + - name: Checkout main + uses: actions/checkout@v4 + with: + repository: ${{ github.repository }} + ref: main + path: main-sdk + token: ${{ secrets.MACHINE_USER_PAT }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "24" + cache: "npm" + cache-dependency-path: | + package-lock.json + + - name: Login to GitHub Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.MACHINE_USER_PAT }} + + - name: Install dependencies + run: npm ci + + - name: Build PHP SDK + run: | + mkdir -p /tmp/sdk + npm run generate /tmp/sdk https://www.rebilly.com/_spec/catalog/all.yaml + cp -r examples /tmp/sdk + + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.1" + extensions: mbstring, intl, curl, json + tools: composer:v2 + + - name: Run Psalm on generated SDK + working-directory: /tmp/sdk + run: | + composer install --no-interaction --no-scripts --prefer-dist + ./vendor/bin/psalm + + - name: Generate diff (generated SDK vs main) + id: text-diff + working-directory: main-sdk + run: | + # Replace main's SDK files with generated ones, then diff + rm -rf src composer.json .php-cs-fixer.php psalm.xml 2>/dev/null || true + cp -r /tmp/sdk/src . + cp /tmp/sdk/composer.json . 2>/dev/null || true + cp /tmp/sdk/psalm.xml . 2>/dev/null || true + cp /tmp/sdk/.php-cs-fixer.php . 2>/dev/null || true + + # Add all files to git to ensure diff works correctly + git add -A || true + + # Generate text diff (staged = generated vs main's content) + git diff --no-color --cached > /tmp/rebilly-sdk-diff.txt || true + + # Check diff size (GitHub PR comment limit is ~65,536 characters) + DIFF_SIZE=$(wc -c < /tmp/rebilly-sdk-diff.txt) + echo "diff_size=$DIFF_SIZE" >> $GITHUB_OUTPUT + echo "Diff size: $DIFF_SIZE bytes" + + if [ "$DIFF_SIZE" -gt 0 ] && [ "$DIFF_SIZE" -lt 60000 ]; then + echo "include_diff=true" >> $GITHUB_OUTPUT + else + echo "include_diff=false" >> $GITHUB_OUTPUT + fi + if [ "$DIFF_SIZE" -eq 0 ]; then + echo "diff_empty=true" >> $GITHUB_OUTPUT + else + echo "diff_empty=false" >> $GITHUB_OUTPUT + fi + + - name: Generate HTML diff + run: | + npx -y diff2html -s side -t 'Rebilly PHP SDK (generated vs main)' -F /tmp/rebilly-sdk-diff.html -i file /tmp/rebilly-sdk-diff.txt || true + + - name: Upload Artifact + id: diff-upload + uses: actions/upload-artifact@v4 + with: + name: rebilly-sdk-diff + path: /tmp/rebilly-sdk-diff.html + retention-days: 3 + if-no-files-found: ignore + + + - name: Prepare PR Comment Message + if: github.event_name == 'pull_request' + id: comment-message + run: | + echo "## 🔍 PHP SDK Changes Preview" >> /tmp/pr-comment.md + echo "" >> /tmp/pr-comment.md + if [ "${{ steps.text-diff.outputs.include_diff }}" == "true" ]; then + { + echo '```diff' + cat /tmp/rebilly-sdk-diff.txt + echo '```' + } >> /tmp/pr-comment.md + else + echo "**Diff is too large to display inline**" >> /tmp/pr-comment.md + fi + echo "" >> /tmp/pr-comment.md + echo "📦 Download the **rebilly-sdk-diff** artifact from the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) for a formatted HTML diff." >> /tmp/pr-comment.md + echo "" >> /tmp/pr-comment.md + echo "---" >> /tmp/pr-comment.md + echo "**Updated:** $(date -u +"%Y-%m-%d %H:%M:%S UTC")" >> /tmp/pr-comment.md + echo "**Commit:** [${{ github.event.pull_request.head.sha }}](${{ github.server_url }}/${{ github.repository }}/commit/${{ github.event.pull_request.head.sha }})" >> /tmp/pr-comment.md + + { + echo "message<> $GITHUB_OUTPUT + + - name: Create/Update PR Comment + if: github.event_name == 'pull_request' + uses: marocchino/sticky-pull-request-comment@v2 + with: + GITHUB_TOKEN: ${{ github.token }} + number: ${{ github.event.pull_request.number }} + message: ${{ steps.comment-message.outputs.message }} diff --git a/package.json b/package.json index 3b1b0de8e..9810f7934 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,6 @@ }, "scripts": { "version": "npx changeset version && npm i && node scripts/update-sdk-version.js", - "generate": "regenerator generate -g rebilly-php -o" + "generate": "regenerator generate -g rebilly-php -c sdk-generator/rebilly-php.config.ts -o" } } diff --git a/rebilly-php.config.ts b/sdk-generator/rebilly-php.config.ts similarity index 99% rename from rebilly-php.config.ts rename to sdk-generator/rebilly-php.config.ts index efc3d1fa2..47ad7bf50 100644 --- a/rebilly-php.config.ts +++ b/sdk-generator/rebilly-php.config.ts @@ -95,7 +95,7 @@ const config: GeneratorConfig = { }, buildConfig: { - outputFilename: 'rebilly-php', + outputFilename: 'templates', }, rootNameSpace: 'Rebilly\\Sdk', @@ -287,7 +287,7 @@ const config: GeneratorConfig = { 'operation-pdf': 'operation-pdf.php.handlebars', }, - templateDirs: ['@bundled/rebilly-php'], + templateDirs: ['./sdk-generator/templates', '@bundled/php'], }; export default config; diff --git a/sdk-generator/templates/file-header.handlebars b/sdk-generator/templates/file-header.handlebars new file mode 100644 index 000000000..51607120e --- /dev/null +++ b/sdk-generator/templates/file-header.handlebars @@ -0,0 +1,10 @@ +/** + * This source file is proprietary and part of Rebilly. + * + * (c) Rebilly SRL + * Rebilly Ltd. + * Rebilly Inc. + * + * @see https://www.rebilly.com + */ + diff --git a/sdk-generator/templates/model-factory-ConfigurablePlan.php.handlebars b/sdk-generator/templates/model-factory-ConfigurablePlan.php.handlebars new file mode 100644 index 000000000..59e4468a9 --- /dev/null +++ b/sdk-generator/templates/model-factory-ConfigurablePlan.php.handlebars @@ -0,0 +1,17 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn model.namespace}}; + +class {{model.className}}Factory +{ + public static function from(array $data = [], array $metadata = []): {{model.className}} + { + if (count($data) === 1 && isset($data['id'])) { + return OriginalPlan::from($data, $metadata); + } + + return FlexiblePlanFactory::from($data, $metadata); + } +} diff --git a/sdk-generator/templates/model-factory-Plan.php.handlebars b/sdk-generator/templates/model-factory-Plan.php.handlebars new file mode 100644 index 000000000..49ad55941 --- /dev/null +++ b/sdk-generator/templates/model-factory-Plan.php.handlebars @@ -0,0 +1,20 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn model.namespace}}; + +class {{model.className}}Factory +{ + public static function from(array $data = [], array $metadata = []): {{model.className}} + { + if ($data['isTrialOnly'] ?? false) { + return TrialOnlyPlan::from($data, $metadata); + } + if (isset($data['recurringInterval'])) { + return SubscriptionPlan::from($data, $metadata); + } + + return OneTimeSalePlan::from($data, $metadata); + } +} diff --git a/sdk-generator/templates/operation-json-collection.php.handlebars b/sdk-generator/templates/operation-json-collection.php.handlebars new file mode 100644 index 000000000..bdfe89c05 --- /dev/null +++ b/sdk-generator/templates/operation-json-collection.php.handlebars @@ -0,0 +1,16 @@ +{{#>operation-layout}} + $response = $this->client->send($request); + $data = Utils::jsonDecode((string) $response->getBody(), true); + + {{#with responseType.[0].containedTypes.[0]}} + return new Collection( + array_map(fn (array $item): {{classname name}} => {{classname name}}{{#if (isFactory name)}}Factory{{/if}}::from($item, ['headers' => $response->getHeaders()]), $data), + (int) $response->getHeaderLine(Collection::HEADER_LIMIT), + (int) $response->getHeaderLine(Collection::HEADER_OFFSET), + (int) $response->getHeaderLine(Collection::HEADER_TOTAL), + [ + 'headers' => $response->getHeaders(), + ] + ); + {{/with}} +{{/operation-layout}} \ No newline at end of file diff --git a/sdk-generator/templates/operation-paginator.php.handlebars b/sdk-generator/templates/operation-paginator.php.handlebars new file mode 100644 index 000000000..104d06beb --- /dev/null +++ b/sdk-generator/templates/operation-paginator.php.handlebars @@ -0,0 +1,17 @@ + {{#if returnTypeHint}} + /** + * @return {{{returnTypeHint}}} + */ + {{/if}} + public function {{methodName}}({{#each arguments as |argument|}} + {{>operation-argument argument}}{{#if @last}} + {{/if}}{{/each}}): {{returnType}} { + $closure = fn (?int $limit, ?int $offset): Collection => $this->{{originalMethodName}}({{#each arguments as |argument|}} + {{argument.name}}: ${{argument.name}},{{#if @last}} + {{/if}}{{/each}}); + + return new {{returnType}}( + $limit !== null || $offset !== null ? $closure(limit: $limit, offset: $offset) : null, + $closure, + ); + } \ No newline at end of file diff --git a/sdk-generator/templates/operation-pdf.php.handlebars b/sdk-generator/templates/operation-pdf.php.handlebars new file mode 100644 index 000000000..9da1969c6 --- /dev/null +++ b/sdk-generator/templates/operation-pdf.php.handlebars @@ -0,0 +1,5 @@ +{{#>operation-layout}} + $response = $this->client->send($request, ['allow_redirects' => ['refer' => true]]); + + return $response->getBody(); +{{/operation-layout}} \ No newline at end of file diff --git a/sdk-generator/templates/static/gitignore.handlebars b/sdk-generator/templates/static/gitignore.handlebars new file mode 100644 index 000000000..8ac6c7183 --- /dev/null +++ b/sdk-generator/templates/static/gitignore.handlebars @@ -0,0 +1,7 @@ +.idea +vendor/ +phpunit.xml +.php-cs-fixer.cache +.phpunit.result.cache +composer.lock +node_modules/ diff --git a/sdk-generator/templates/static/php-cs-fixer.php.handlebars b/sdk-generator/templates/static/php-cs-fixer.php.handlebars new file mode 100644 index 000000000..5aa28aaf7 --- /dev/null +++ b/sdk-generator/templates/static/php-cs-fixer.php.handlebars @@ -0,0 +1,116 @@ +file-header}} +declare(strict_types=1); + +use PhpCsFixer\Config; +use PhpCsFixer\Finder; +use PhpCsFixer\Runner\Parallel\ParallelConfigFactory; + +$header = <<<'EOF' +This source file is proprietary and part of Rebilly. + +(c) Rebilly SRL + Rebilly Ltd. + Rebilly Inc. + +@see https://www.rebilly.com +EOF; + +$rules = [ + '@PSR12' => true, + 'align_multiline_comment' => true, + 'array_syntax' => ['syntax' => 'short'], + 'array_indentation' => true, + 'blank_line_before_statement' => true, + 'binary_operator_spaces' => true, + 'cast_spaces' => true, + 'class_attributes_separation' => true, + 'combine_consecutive_issets' => true, + 'combine_consecutive_unsets' => true, + 'compact_nullable_typehint' => true, + 'concat_space' => ['spacing' => 'one'], + 'header_comment' => [ + 'header' => $header, + 'comment_type' => 'PHPDoc', + 'separate' => 'bottom', + 'location' => 'after_open', + ], + 'heredoc_to_nowdoc' => true, + 'general_phpdoc_annotation_remove' => ['annotations' => ['author', 'version']], + 'list_syntax' => ['syntax' => 'short'], + 'lowercase_cast' => true, + 'magic_constant_casing' => true, + 'mb_str_functions' => true, + 'method_argument_space' => ['on_multiline' => 'ensure_fully_multiline'], + 'modernize_types_casting' => true, + 'native_function_casing' => true, + 'new_with_braces' => true, + 'no_empty_comment' => true, + 'no_empty_phpdoc' => true, + 'no_empty_statement' => true, + 'no_extra_blank_lines' => ['tokens' => ['break', 'continue', 'extra', 'return', 'throw', 'use', 'parenthesis_brace_block', 'square_brace_block', 'curly_brace_block', 'switch', 'case', 'default']], + 'no_homoglyph_names' => true, + 'no_leading_import_slash' => true, + 'no_leading_namespace_whitespace' => true, + 'no_mixed_echo_print' => true, + 'no_multiline_whitespace_around_double_arrow' => true, + 'no_null_property_initialization' => true, + 'no_short_bool_cast' => true, + 'no_singleline_whitespace_before_semicolons' => true, + 'no_spaces_around_offset' => true, + 'no_superfluous_elseif' => true, + 'no_trailing_comma_in_singleline' => true, + 'no_unneeded_control_parentheses' => true, + 'no_unneeded_curly_braces' => true, + 'no_unneeded_final_method' => true, + 'no_unreachable_default_argument_value' => true, + 'no_unused_imports' => true, + 'no_useless_else' => true, + 'no_useless_return' => true, + 'no_whitespace_before_comma_in_array' => true, + 'no_whitespace_in_blank_line' => true, + 'normalize_index_brace' => true, + 'ordered_class_elements' => true, + 'ordered_imports' => true, + 'php_unit_strict' => true, + 'phpdoc_add_missing_param_annotation' => true, + 'phpdoc_order' => true, + 'phpdoc_types_order' => true, + 'protected_to_private' => true, + 'return_type_declaration' => true, + 'self_accessor' => true, + 'semicolon_after_instruction' => true, + 'short_scalar_cast' => true, + 'single_line_comment_style' => true, + 'single_quote' => true, + 'standardize_not_equals' => true, + 'strict_comparison' => true, + 'strict_param' => true, + 'ternary_to_null_coalescing' => true, + 'trailing_comma_in_multiline' => ['elements' => []], + 'trim_array_spaces' => true, + 'unary_operator_spaces' => true, + 'visibility_required' => ['elements' => ['property', 'method', 'const']], + 'void_return' => true, + 'yoda_style' => ['equal' => false, 'identical' => false], + 'object_operator_without_whitespace' => true, + 'ternary_operator_spaces' => true, + 'fully_qualified_strict_types' => true, + 'global_namespace_import' => [ + 'import_constants' => false, + 'import_functions' => false, + 'import_classes' => true, + ], + 'single_space_around_construct' => true, +]; + +$finder = (new Finder()) + ->exclude('vendor') + ->in(__DIR__); + +return (new Config()) + ->setParallelConfig(ParallelConfigFactory::detect()) + ->setFinder($finder) + ->setRules($rules) + ->setRiskyAllowed(true) + ->setUsingCache(true); \ No newline at end of file diff --git a/sdk-generator/templates/static/psalm.xml.handlebars b/sdk-generator/templates/static/psalm.xml.handlebars new file mode 100644 index 000000000..654b71a81 --- /dev/null +++ b/sdk-generator/templates/static/psalm.xml.handlebars @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sdk-generator/templates/static/src/Client.php.handlebars b/sdk-generator/templates/static/src/Client.php.handlebars new file mode 100644 index 000000000..d0f8f2405 --- /dev/null +++ b/sdk-generator/templates/static/src/Client.php.handlebars @@ -0,0 +1,172 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}; + +use Error; +use GuzzleHttp\Client as GuzzleClient; +use GuzzleHttp\ClientInterface as GuzzleClientInterface; +use GuzzleHttp\HandlerStack; +use GuzzleHttp\Middleware; +use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\Uri; +use GuzzleHttp\Utils; +use Psr\Http\Client\ClientInterface as PsrClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use {{fqn codegen.rootNameSpace}}\Middleware\ApiKeyAuthentication; +use {{fqn codegen.rootNameSpace}}\Middleware\BaseUri; +use {{fqn codegen.rootNameSpace}}\Middleware\BearerAuthentication; +use {{fqn codegen.rootNameSpace}}\Middleware\ErrorHandler; +use {{fqn codegen.rootNameSpace}}\Middleware\UserAgent; + +/** + * @mixin Service + */ +final class Client implements GuzzleClientInterface, PsrClientInterface +{ + public const BASE_HOST = 'https://api.rebilly.com'; + + public const SANDBOX_HOST = 'https://api-sandbox.rebilly.com'; + + public const EXPERIMENTAL_BASE = '/experimental'; + + public const SDK_VERSION = '3.0.0'; + + private GuzzleClient $client; + + /** + * @param array{ + * apiKey?: string, + * sessionToken?: string, + * baseUrl?: string, + * organizationId ?: string, + * timeout?: int, + * allow_redirects?: bool, + * proxy ?: string, + * handler ?: callable + * } $config + */ + public function __construct(array $config = []) + { + $stack = new HandlerStack(Utils::chooseHandler()); + + $stack->push(new ErrorHandler(), 'http_errors'); + $stack->push(Middleware::redirect(), 'allow_redirects'); + $stack->push(Middleware::prepareBody(), 'prepare_body'); + + if (isset($config['apiKey'])) { + $authentication = new ApiKeyAuthentication($config['apiKey']); + $stack->push($authentication); + } elseif (isset($config['sessionToken'])) { + $authentication = new BearerAuthentication($config['sessionToken']); + $stack->push($authentication); + } + + if (isset($config['baseUrl'])) { + $config['baseUrl'] = ltrim($config['baseUrl'], '/'); + } else { + $config['baseUrl'] = self::BASE_HOST; + } + + $stack->push( + new BaseUri( + $this->createUri($config['baseUrl']), + $config['organizationId'] ?? null + ) + ); + + $stack->push(new UserAgent(self::SDK_VERSION)); + + unset($config['baseUrl'], $config['apiKey'], $config['sessionToken'], $config['organizationId']); + + $config['handler'] = $stack; + + $this->client = new GuzzleClient($config); + } + + public function __call(string $name, array $arguments): mixed + { + if (is_callable([$this->combined(), $name])) { + return $this->service()->{$name}(...$arguments); + } + + throw new Error(sprintf('Call to undefined method %s::%s().', __CLASS__, $name)); + } + + /** + * @deprecated + */ + public function combined(): CombinedService + { + return new CombinedService(client: $this); + } + + public function service(): Service + { + return new Service(client: $this); + } + + public function createUri($uri, array $params = []): Uri + { + if ($uri instanceof Uri) { + if (!empty($params)) { + $uri = $uri->withQuery(http_build_query($params)); + } + + return $uri; + } + + // If URL template given, prepare URI + if (preg_match_all('/{\w+}/i', $uri, $matches)) { + foreach (array_unique($matches[0]) as $match) { + $param = mb_substr($match, 1, -1); + + if (isset($params[$param])) { + $uri = str_replace($match, $params[$param], $uri); + unset($params[$param]); + } + } + } + + if (!empty($params)) { + $uri .= '?' . http_build_query($params); + } + + return new Uri($uri); + } + + public function send(RequestInterface $request, array $options = []): ResponseInterface + { + return $this->client->send($request, $options); + } + + public function sendAsync(RequestInterface $request, array $options = []): PromiseInterface + { + return $this->client->sendAsync($request, $options); + } + + public function request(string $method, $uri, array $options = []): ResponseInterface + { + return $this->client->request($method, $uri, $options); + } + + public function requestAsync(string $method, $uri, array $options = []): PromiseInterface + { + return $this->client->requestAsync($method, $uri, $options); + } + + public function sendRequest(RequestInterface $request): ResponseInterface + { + return $this->client->sendRequest($request); + } + + /** + * @deprecated + */ + public function getConfig(?string $option = null) + { + return $this->client->getConfig($option); + } +} diff --git a/sdk-generator/templates/static/src/CombinedService.php.handlebars b/sdk-generator/templates/static/src/CombinedService.php.handlebars new file mode 100644 index 000000000..20928716a --- /dev/null +++ b/sdk-generator/templates/static/src/CombinedService.php.handlebars @@ -0,0 +1,12 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}; + +/** + * @deprecated + */ +class CombinedService extends Service +{ +} diff --git a/sdk-generator/templates/static/src/Exception/DataValidationException.php.handlebars b/sdk-generator/templates/static/src/Exception/DataValidationException.php.handlebars new file mode 100644 index 000000000..9304956d6 --- /dev/null +++ b/sdk-generator/templates/static/src/Exception/DataValidationException.php.handlebars @@ -0,0 +1,26 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}\Exception; + +use Exception; + +final class DataValidationException extends HttpException +{ + private array $validationErrors = []; + + public function __construct(array $content = [], $message = '', $code = 0, Exception $previous = null) + { + if (isset($content['invalidFields']) && is_array($content['invalidFields'])) { + $this->validationErrors = $content['invalidFields']; + } + + parent::__construct(422, $message ?: 'Data Validation Failed.', $code, $previous); + } + + public function getValidationErrors(): array + { + return $this->validationErrors; + } +} diff --git a/sdk-generator/templates/static/src/Middleware/ApiKeyAuthentication.php.handlebars b/sdk-generator/templates/static/src/Middleware/ApiKeyAuthentication.php.handlebars new file mode 100644 index 000000000..d07650abe --- /dev/null +++ b/sdk-generator/templates/static/src/Middleware/ApiKeyAuthentication.php.handlebars @@ -0,0 +1,24 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}\Middleware; + +use Closure; +use Psr\Http\Message\RequestInterface; + +final class ApiKeyAuthentication +{ + public const HEADER = 'REB-APIKEY'; + + public function __construct(private string $key) + { + } + + public function __invoke(callable $next): Closure + { + return function (RequestInterface $request, array $options) use ($next) { + return $next($request->withHeader(self::HEADER, $this->key), $options); + }; + } +} diff --git a/sdk-generator/templates/static/src/Middleware/BaseUri.php.handlebars b/sdk-generator/templates/static/src/Middleware/BaseUri.php.handlebars new file mode 100644 index 000000000..94c4d2179 --- /dev/null +++ b/sdk-generator/templates/static/src/Middleware/BaseUri.php.handlebars @@ -0,0 +1,71 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}\Middleware; + +use Closure; +use GuzzleHttp\Psr7\Uri; +use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\RequestInterface; +use {{fqn codegen.rootNameSpace}}\Client; + +final class BaseUri +{ + public function __construct(private ?Uri $uri = null, private ?string $organizationId = null) + { + } + + public function __invoke(callable $next): Closure + { + return function (RequestInterface $request, array $options) use ($next) { + if ($request->getHeaderLine('Host') !== '') { + return $next($request, $options); + } + $newPath = $this->adjustUriPath($request->getUri()->getPath()); + $request = $request->withUri( + $this->uri->withPath($newPath) + ->withQuery($request->getUri()->getQuery()), + ); + + return $next($request, $options)->then( + function (ResponseInterface $response) { + if ($response->getHeaderLine('Location') === '') { + return $response; + } + $locationUri = new Uri($response->getHeaderLine('Location')); + $newPath = $this->adjustUriPath($locationUri->getPath()); + + return $response->withHeader( + 'Location', + $this->uri->withPath($newPath) + ->withQuery($locationUri->getQuery()) + ->__toString(), + ); + }, + ); + }; + } + + private function adjustUriPath(string $requestPath): string + { + $basePath = $this->uri->getPath(); + + if ( + str_starts_with($requestPath, Client::EXPERIMENTAL_BASE) + && !str_ends_with(rtrim($basePath, '/'), ltrim(Client::EXPERIMENTAL_BASE, '/')) + ) { + $basePath .= Client::EXPERIMENTAL_BASE; + $requestPath = mb_substr($requestPath, mb_strlen(Client::EXPERIMENTAL_BASE)); + } + $basePath .= '/'; + if ($this->organizationId) { + $organizationPrefix = 'organizations/' . $this->organizationId . '/'; + if (!str_starts_with(ltrim($requestPath, '/'), $organizationPrefix)) { + $basePath .= $organizationPrefix; + } + } + + return $basePath . ltrim($requestPath, '/'); + } +} diff --git a/sdk-generator/templates/static/src/Middleware/ErrorHandler.php.handlebars b/sdk-generator/templates/static/src/Middleware/ErrorHandler.php.handlebars new file mode 100644 index 000000000..af758c515 --- /dev/null +++ b/sdk-generator/templates/static/src/Middleware/ErrorHandler.php.handlebars @@ -0,0 +1,57 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}\Middleware; + +use Closure; +use GuzzleHttp\Exception\RequestException; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use {{fqn codegen.rootNameSpace}}\Exception; + +final class ErrorHandler +{ + public function __invoke(callable $next): Closure + { + return function (RequestInterface $request, array $options) use ($next) { + return $next($request, $options) + ->then(function (ResponseInterface $response) use ($request): ResponseInterface { + $code = $response->getStatusCode(); + if ($code < 400) { + return $response; + } + if ($code === 404) { + throw new Exception\NotFoundException(); + } + + if ($code === 410) { + throw new Exception\GoneException(); + } + + if ($code === 422) { + $content = json_decode($response->getBody()->getContents(), true); + + throw new Exception\DataValidationException($content ?? []); + } + + if ($code === 429) { + throw new Exception\TooManyRequestsException( + $response->getHeaderLine('Retry-After'), + $response->getHeaderLine('Rate-Limit-Limit'), + 'Too many requests, retry after ' . $response->getHeaderLine('Retry-After') + ); + } + + if ($code >= 500) { + throw new Exception\ServerException( + $response->getStatusCode(), + $response->getReasonPhrase(), + ); + } + + throw RequestException::create($request, $response); + }); + }; + } +} diff --git a/sdk-generator/templates/static/src/Middleware/UserAgent.php.handlebars b/sdk-generator/templates/static/src/Middleware/UserAgent.php.handlebars new file mode 100644 index 000000000..bcf196667 --- /dev/null +++ b/sdk-generator/templates/static/src/Middleware/UserAgent.php.handlebars @@ -0,0 +1,41 @@ +file-header}} +declare(strict_types=1); + +namespace {{fqn codegen.rootNameSpace}}\Middleware; + +use Closure; +use Psr\Http\Message\RequestInterface; + +final class UserAgent +{ + public function __construct(private ?string $sdkVersion) + { + } + + public function __invoke(callable $next): Closure + { + return function (RequestInterface $request, array $options) use ($next) { + $release = @php_uname('r') ?: 'unknown'; + $machine = @php_uname('m') ?: 'unknown'; + + return $next($request->withHeader( + 'User-Agent', + self::generateUserAgentName( + 'PHP-SDK', + $this->sdkVersion, + [ + 'platform-ver=' . PHP_VERSION, + 'os=' . str_replace(' ', '_', PHP_OS . ' ' . $release), + 'machine=' . $machine, + ] + ) + ), $options); + }; + } + + private static function generateUserAgentName($name, $version, array $features): string + { + return sprintf('RebillySDK/%s %s (%s)', $name, $version, implode('; ', $features)); + } +} From 4908305c194e36f705e7561232bb007487bc06ab Mon Sep 17 00:00:00 2001 From: Loic Gouttefangeas Date: Mon, 9 Mar 2026 12:11:02 +0100 Subject: [PATCH 2/4] build(ci): Fix SDK preview on SDK generator update --- .github/workflows/preview.yaml | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 5b136d39e..267662342 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -50,20 +50,6 @@ jobs: npm run generate /tmp/sdk https://www.rebilly.com/_spec/catalog/all.yaml cp -r examples /tmp/sdk - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: "8.1" - extensions: mbstring, intl, curl, json - tools: composer:v2 - - - name: Run Psalm on generated SDK - working-directory: /tmp/sdk - run: | - composer install --no-interaction --no-scripts --prefer-dist - ./vendor/bin/psalm - - name: Generate diff (generated SDK vs main) id: text-diff working-directory: main-sdk @@ -99,7 +85,7 @@ jobs: - name: Generate HTML diff run: | - npx -y diff2html -s side -t 'Rebilly PHP SDK (generated vs main)' -F /tmp/rebilly-sdk-diff.html -i file /tmp/rebilly-sdk-diff.txt || true + npx -y diff2html-cli -s side -t 'Rebilly PHP SDK (generated vs main)' -F /tmp/rebilly-sdk-diff.html -i file /tmp/rebilly-sdk-diff.txt || true - name: Upload Artifact id: diff-upload From 0755550e7f65abb8d601f008bfa018a79322c61d Mon Sep 17 00:00:00 2001 From: Loic Gouttefangeas Date: Mon, 9 Mar 2026 12:15:03 +0100 Subject: [PATCH 3/4] build(ci): Fix SDK preview on SDK generator update --- .github/workflows/preview.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 267662342..7cff2fd2f 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -85,7 +85,7 @@ jobs: - name: Generate HTML diff run: | - npx -y diff2html-cli -s side -t 'Rebilly PHP SDK (generated vs main)' -F /tmp/rebilly-sdk-diff.html -i file /tmp/rebilly-sdk-diff.txt || true + npx -y diff2html-cli -s side -t 'Rebilly PHP SDK' -F /tmp/rebilly-sdk-diff.html - name: Upload Artifact id: diff-upload From 9b43a7c7332d0cec76a0069e2c954d12af4ea904 Mon Sep 17 00:00:00 2001 From: Loic Gouttefangeas Date: Mon, 9 Mar 2026 12:24:14 +0100 Subject: [PATCH 4/4] build(ci): Fix SDK preview on SDK generator update --- .github/workflows/preview.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/preview.yaml b/.github/workflows/preview.yaml index 7cff2fd2f..53c364724 100644 --- a/.github/workflows/preview.yaml +++ b/.github/workflows/preview.yaml @@ -84,6 +84,7 @@ jobs: fi - name: Generate HTML diff + working-directory: main-sdk run: | npx -y diff2html-cli -s side -t 'Rebilly PHP SDK' -F /tmp/rebilly-sdk-diff.html