diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..ef5705b --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,116 @@ +# Manual publish: validate, test, then create a GitHub Release and tag. +# In GitHub: Actions → Publish Supaship PHP SDK → Run workflow → enter version (e.g. 1.0.0). +# Packagist still updates from the new tag if the repo webhook is configured (see DEPLOY.md). + +name: Publish Supaship PHP SDK + +on: + workflow_dispatch: + inputs: + version: + description: 'Version to release (e.g. 1.2.0 or v1.2.0)' + required: true + type: string + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository (full history, all tags) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + fetch-tags: true + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: json, openssl + coverage: none + + - name: Validate composer.json + run: composer validate --no-check-publish --strict + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run tests + run: composer test + + - name: Resolve version tag + id: version + env: + RAW_VERSION: ${{ github.event.inputs.version }} + run: | + set -euo pipefail + STRIPPED="${RAW_VERSION#v}" + if ! printf '%s' "$STRIPPED" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.]+)?(\+[a-zA-Z0-9.]+)?$'; then + echo "::error::Version must be semver-like, e.g. 1.0.0 or v1.0.0 (optional pre-release: 1.0.0-beta.1)" + exit 1 + fi + TAG="v${STRIPPED}" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "stripped=${STRIPPED}" >> "$GITHUB_OUTPUT" + + - name: Build release notes from commits since last GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + REPO="${{ github.repository }}" + NOTES_FILE="${GITHUB_WORKSPACE}/release-notes.md" + + PREV_TAG=$(gh api "repos/${REPO}/releases/latest" --jq '.tag_name // empty' 2>/dev/null || true) + + if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "null" ]; then + PREV_TAG=$(git describe --tags --abbrev=0 2>/dev/null || true) + fi + + HEADER="## Changes since previous release" + if [ -z "$PREV_TAG" ]; then + HEADER="## Changes (no prior GitHub release or git tag found)" + LOG=$(git log --pretty=format:'- %s (%h)' --no-merges -n 200) + elif git rev-parse "${PREV_TAG}^{commit}" >/dev/null 2>&1; then + LOG=$(git log "${PREV_TAG}..HEAD" --pretty=format:'- %s (%h)' --no-merges) + else + HEADER="## Changes (previous tag \`${PREV_TAG}\` not found locally — showing recent history)" + LOG=$(git log --pretty=format:'- %s (%h)' --no-merges -n 200) + fi + + if [ -z "$LOG" ]; then + LOG="_No new commits in this range._" + fi + + { + echo "$HEADER" + echo "" + echo "$LOG" + echo "" + } > "$NOTES_FILE" + + - name: Create GitHub Release (and tag) + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + TAG="${{ steps.version.outputs.tag }}" + NOTES_FILE="${GITHUB_WORKSPACE}/release-notes.md" + + if gh release view "$TAG" --repo "${{ github.repository }}" >/dev/null 2>&1; then + echo "::error::Release or tag ${TAG} already exists. Bump the version." + exit 1 + fi + + gh release create "$TAG" \ + --repo "${{ github.repository }}" \ + --target "${{ github.sha }}" \ + --title "$TAG" \ + --notes-file "$NOTES_FILE" + + echo "Created ${TAG}. If Packagist is linked, it should pick up this tag shortly." + echo "See DEPLOY.md for troubleshooting." diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 4b10727..0000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,37 +0,0 @@ -# Runs full checks when you push a version tag (e.g. v1.0.0). -# Publishing to Packagist still requires a one-time setup (see DEPLOY.md): Packagist pulls from GitHub when tags appear. - -name: Release Supaship PHP SDK - -on: - push: - tags: - - 'v*' - -jobs: - verify: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v4 - - - name: Set up PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.2' - extensions: json, openssl - coverage: none - - - name: Validate composer.json - run: composer validate --no-check-publish --strict - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Run tests - run: composer test - - - name: Package name reminder - run: | - echo "If Packagist is linked to this repo, it will pick up tag ${GITHUB_REF_NAME} automatically (may take a minute)." - echo "See DEPLOY.md if the version does not appear on https://packagist.org/packages/supashiphq/php-sdk" diff --git a/README.md b/README.md index 5a5a7ef..402eed8 100644 --- a/README.md +++ b/README.md @@ -82,13 +82,526 @@ Optional per-request context: $client->getFeature('new-ui', ['context' => ['plan' => 'enterprise']]); ``` +## Framework integrations + +`SupaClient` has no framework-specific code paths: register it once in the container (or a bootstrap file), inject it where you need flags, and call `getFeature` / `getFeatures`. All three examples assume `composer require supashiphq/php-sdk` is already done. + +Evaluations are **synchronous** (each call waits for the HTTP response unless you wrap them yourself, e.g. queue or async jobs). + +--- + +### Laravel + +**1. Environment** + +In `.env`: + +```env +SUPASHIP_SDK_KEY=your-sdk-key +SUPASHIP_ENVIRONMENT=production +``` + +**2. Config file** (e.g. `config/supaship.php`) + +```php + env('SUPASHIP_SDK_KEY'), + 'environment' => env('SUPASHIP_ENVIRONMENT', 'production'), + /** + * Central list of flags and fallbacks — keep in sync with what you use in Supaship. + */ + 'features' => [ + 'new-ui' => false, + 'theme-config' => [ + 'primaryColor' => '#007bff', + 'darkMode' => false, + ], + ], +]; +``` + +**3. Register the client** in `app/Providers/AppServiceProvider.php` (method `register()`): + +```php +use Illuminate\Support\ServiceProvider; +use Supaship\SupaClient; + +class AppServiceProvider extends ServiceProvider +{ + public function register(): void + { + $this->app->singleton(SupaClient::class, function ($app) { + $config = $app['config']->get('supaship'); + + return new SupaClient([ + 'sdkKey' => $config['sdk_key'], + 'environment' => $config['environment'], + 'features' => $config['features'], + 'context' => [ + // Filled at boot or request time — see below + 'appEnv' => config('app.env'), + ], + ]); + }); + } +} +``` + +**4. Per-request context** (e.g. after auth) + +In `AppServiceProvider::boot()` or a middleware: + +```php +use Illuminate\Support\Facades\Auth; +use Supaship\SupaClient; + +public function boot(): void +{ + $this->app->afterResolving(SupaClient::class, function (SupaClient $client) { + $user = Auth::user(); + if ($user) { + $client->updateContext([ + 'userId' => (string) $user->id, + 'email' => $user->email ?? '', + ]); + } + }); +} +``` + +**5. Use in a controller** + +```php +use Supaship\SupaClient; + +class DashboardController extends Controller +{ + public function __construct(private readonly SupaClient $features) {} + + public function index() + { + $showNewUi = $this->features->getFeature('new-ui'); + $theme = $this->features->getFeature('theme-config'); + + return view('dashboard', compact('showNewUi', 'theme')); + } +} +``` + +Optional **middleware** that only refreshes context is often cleaner than `afterResolving` when you need `Auth::user()` on every request. + +--- + +### Symfony + +**1. Environment** + +In `.env.local` (do not commit secrets): + +```env +SUPASHIP_SDK_KEY=your-sdk-key +SUPASHIP_ENVIRONMENT=production +``` + +**2. Parameters** in `config/services.yaml` (Symfony 6/7 style): + +```yaml +parameters: + supaship.sdk_key: '%env(SUPASHIP_SDK_KEY)%' + supaship.environment: '%env(SUPASHIP_ENVIRONMENT)%' + supaship.features: + new-ui: false + theme-config: + primaryColor: '#007bff' + darkMode: false +``` + +For larger `features` maps, you can load a dedicated file with `imports:` or define the array in PHP via a small config class; the important part is passing the same structure into `SupaClient`. + +**3. Service definition** in `config/services.yaml`: + +```yaml +services: + Supaship\SupaClient: + class: Supaship\SupaClient + arguments: + - { + sdkKey: '%supaship.sdk_key%', + environment: '%supaship.environment%', + features: '%supaship.features%', + context: { appEnv: '%kernel.environment%' } + } + public: true +``` + +**4. Subscriber** to attach the current user to the client (example using Symfony security): + +```php +isMainRequest()) { + return; + } + + $user = $this->security->getUser(); + if ($user === null) { + return; + } + + $this->client->updateContext([ + // Adjust to your User class / identifier field + 'userId' => method_exists($user, 'getUserIdentifier') + ? $user->getUserIdentifier() + : (string) spl_object_id($user), + ]); + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::REQUEST => ['onKernelRequest', 8]]; + } +} +``` + +Register the subscriber (Symfony auto-wires if `App\` is configured; otherwise add explicit service tags for `kernel.event_subscriber`). + +**5. Controller** + +```php +use Supaship\SupaClient; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\Routing\Attribute\Route; + +final class HomeController extends AbstractController +{ + #[Route('/', name: 'home')] + public function index(SupaClient $features): Response + { + $newUi = $features->getFeature('new-ui'); + + return $this->render('home.html.twig', ['new_ui' => $newUi]); + } +} +``` + +--- + +### CodeIgniter 4 + +**1. Environment** + +In `.env`: + +```env +supaship.sdkKey = "your-sdk-key" +supaship.environment = "production" +``` + +**2. Central feature map** + +Create `app/Config/SupashipFeatures.php` (or keep the array inside `Services` if you prefer): + +```php + */ + public static function fallbacks(): array + { + return [ + 'new-ui' => false, + 'theme-config' => [ + 'primaryColor' => '#007bff', + 'darkMode' => false, + ], + ]; + } +} +``` + +**3. Service registration** in `app/Config/Services.php`: + +```php + getenv('supaship.sdkKey') ?: '', + 'environment' => getenv('supaship.environment') ?: 'production', + 'features' => SupashipFeatures::fallbacks(), + 'context' => [ + 'ciEnv' => ENVIRONMENT, + ], + ]); + } +} +``` + +**4. Per-request context** — e.g. in a filter `app/Filters/SupashipContext.php`: + +```php +get('userId')) { + $client->updateContext([ + 'userId' => (string) $session->get('userId'), + ]); + } + } + + public function after(RequestInterface $request, ResponseInterface $response, $arguments = null) + { + } +} +``` + +Register the filter in `app/Config/Filters.php` for the routes or groups that need targeting. + +**5. Controller** + +```php + $features->getFeature('new-ui'), + 'theme' => $features->getFeature('theme-config'), + ]; + + return view('home', $data); + } +} +``` + +--- + +### Shared tips (all frameworks) + +- Keep **`features`** (fallback map) in one file so it stays aligned with names in the Supaship dashboard. +- Use **`sensitiveContextProperties`** in the client config when passing emails or IDs you want hashed before they leave your server (same as the JavaScript SDK). +- Prefer **`getFeatures(['a','b','c'])`** for a single HTTP round-trip instead of many **`getFeature`** calls when loading a page. + +## Testing + +Unit tests should **not** call Supaship Edge. Pass an **`httpHandler`** under `networkConfig` (same hook as in [Advanced: `httpHandler`](#advanced-httphandler)) so `SupaClient` never opens a socket. + +### `Supaship\Testing\HttpStub` + +The package includes a tiny helper so you do not hand-build JSON for every test: + +```php +use Supaship\SupaClient; +use Supaship\Testing\HttpStub; + +$client = new SupaClient([ + 'sdkKey' => 'test-key', + 'environment' => 'test', + 'features' => [ + 'new-ui' => false, + 'theme-config' => ['darkMode' => false], + ], + 'context' => ['userId' => '42'], + 'networkConfig' => [ + 'httpHandler' => HttpStub::success([ + 'new-ui' => true, + 'theme-config' => ['darkMode' => true, 'primaryColor' => '#111'], + ]), + ], +]); + +$this->assertTrue($client->getFeature('new-ui')); +``` + +Simulate API or transport failures (client falls back to your configured defaults): + +```php +'networkConfig' => [ + 'httpHandler' => HttpStub::failure(503, 'unavailable'), +], +``` + +### Laravel: testing a route that injects `SupaClient` + +Your app resolves `SupaClient` from the container (e.g. `AppServiceProvider` registers a **singleton**). In a feature test you **swap** that binding for a client wired with **`HttpStub`**, then call the route. Laravel will inject your test double instead of the real client. + +Assume this route (simplified): + +```php +Route::get('/', function (SupaClient $client) { + $isNewUi = $client->getFeature('cool-new-feature', ['context' => [ + 'userId' => '123', + ]]); + + return $isNewUi ? view('new-welcome') : view('welcome'); +}); +``` + +Use the **same `features` fallback map** shape as in production (at least the keys you request). Register a client whose `httpHandler` returns the flag value you want for that test: + +```php + 'test', + 'environment' => 'testing', + 'features' => [ + 'cool-new-feature' => false, + ], + 'context' => [], + 'networkConfig' => [ + 'httpHandler' => HttpStub::success([ + 'cool-new-feature' => $coolNewFeatureEnabled, + ]), + ], + ]); + } + + public function test_home_uses_welcome2_when_flag_is_true(): void + { + $this->app->instance(SupaClient::class, $this->clientWithFlag(true)); + + $this->get('/') + ->assertOk() + ->assertViewIs('new-welcome'); + } + + public function test_home_uses_welcome_when_flag_is_false(): void + { + $this->app->instance(SupaClient::class, $this->clientWithFlag(false)); + + $this->get('/') + ->assertOk() + ->assertViewIs('welcome'); + } +} +``` + +Why this works: + +- **`$this->app->instance(SupaClient::class, …)`** tells Laravel: “when anything needs `SupaClient`, use this instance.” It runs **before** `$this->get('/')`, so the closure receives your stubbed client. +- **`HttpStub::success([...])`** simulates Edge returning that variation, so **`getFeature`** never performs a real HTTP request. +- To test **fallback** behavior (e.g. Edge down), use **`HttpStub::failure()`** and assert `welcome` if your fallback for `cool-new-feature` is false. + +### Asserting what would be sent to Edge + +The handler receives the POST URL and the **request body string** (JSON). Capture it in a closure when you care about `environment`, `features`, or `context`: + +```php +$captured = null; + +$client = new SupaClient([ + 'sdkKey' => 'sk', + 'environment' => 'staging', + 'features' => ['promo' => false], + 'context' => ['region' => 'eu'], + 'networkConfig' => [ + 'httpHandler' => function (string $url, string $jsonBody) use (&$captured) { + $captured = json_decode($jsonBody, true, flags: JSON_THROW_ON_ERROR); + + return ['statusCode' => 200, 'body' => '{"features":{"promo":{"variation":true}}}']; + }, + ], +]); + +$client->getFeatures(['promo'], ['context' => ['plan' => 'pro']]); + +$this->assertSame('staging', $captured['environment']); +$this->assertSame(['promo'], $captured['features']); +$this->assertSame(['region' => 'eu', 'plan' => 'pro'], $captured['context']); +``` + +### PHPUnit in your app + +Add a dev dependency and point to your tests directory (typical `phpunit.xml.dist`): + +```bash +composer require --dev phpunit/phpunit +``` + +Then run: + +```bash +vendor/bin/phpunit +``` + +The SDK’s own test suite is `composer test` from a clone of this repository (`vendor/bin/phpunit` after `composer install`). + ## Constants `Supaship\Constants::DEFAULT_FEATURES_URL` and `DEFAULT_EVENTS_URL` match the JavaScript SDK defaults. ## Advanced: `httpHandler` -For custom HTTP stacks or tests, you can inject a handler (same idea as `fetchFn` in the JavaScript SDK): +For **production** custom HTTP (proxy, corporate CA, tracing), or ad-hoc test doubles, inject a handler (same idea as `fetchFn` in the JavaScript SDK). For most unit tests, prefer **`HttpStub`** in the [Testing](#testing) section. ```php $client = new SupaClient([ @@ -106,14 +619,16 @@ $client = new SupaClient([ The handler must return `['statusCode' => int, 'body' => string]` where `body` is the raw JSON response. -## Developing & tests +## Developing & tests (this repository) ```bash composer install composer test ``` -Maintainers: see **[DEPLOY.md](DEPLOY.md)** for registering the package on Packagist, webhooks, and version tags. +This runs PHPUnit on `tests/`, including stub coverage for **`Supaship\Testing\HttpStub`**. + +Maintainers: To ship a version from GitHub, use **Actions → Publish Supaship PHP SDK** (`publish.yml`): it validates, runs tests, and opens a GitHub Release with auto-generated notes from commits since the last release. ## License diff --git a/composer.json b/composer.json index 7da3c49..45db33a 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "supashiphq/php-sdk", - "description": "Lightweight PHP SDK for Supaship feature flags (no runtime dependencies)", + "description": "PHP SDK for Supaship feature flags", "type": "library", "license": "MIT", "require": { diff --git a/src/Testing/HttpStub.php b/src/Testing/HttpStub.php new file mode 100644 index 0000000..080b8a3 --- /dev/null +++ b/src/Testing/HttpStub.php @@ -0,0 +1,46 @@ + $variations feature name => variation value (Edge: features[name].variation) + */ + public static function success(array $variations = [], int $statusCode = 200): \Closure + { + $features = []; + foreach ($variations as $name => $value) { + $features[$name] = ['variation' => $value]; + } + + try { + $json = json_encode(['features' => $features], JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new \RuntimeException($e->getMessage(), 0, $e); + } + + return static function (string $url, string $jsonBody) use ($statusCode, $json): array { + return ['statusCode' => $statusCode, 'body' => $json]; + }; + } + + /** + * Handler that returns a non-2xx status or arbitrary body (simulates errors). + */ + public static function failure(int $statusCode = 500, string $body = ''): \Closure + { + return static function (string $url, string $jsonBody) use ($statusCode, $body): array { + return ['statusCode' => $statusCode, 'body' => $body]; + }; + } +} diff --git a/tests/HttpStubTest.php b/tests/HttpStubTest.php new file mode 100644 index 0000000..560c1c7 --- /dev/null +++ b/tests/HttpStubTest.php @@ -0,0 +1,36 @@ + true]); + $decoded = json_decode($handler('', '{}')['body'], true, flags: JSON_THROW_ON_ERROR); + + self::assertSame(['flag' => ['variation' => true]], $decoded['features']); + } + + public function testClientUsesStubWithoutNetwork(): void + { + $client = new SupaClient([ + 'sdkKey' => 'test', + 'environment' => 'test', + 'features' => ['x' => false], + 'context' => [], + 'networkConfig' => [ + 'featuresAPIUrl' => 'https://test.local/features', + 'httpHandler' => HttpStub::success(['x' => true]), + ], + ]); + + self::assertTrue($client->getFeature('x')); + } +}