From 1b63c7e060b425e7ea4587f474ac2cfb1458a85f Mon Sep 17 00:00:00 2001 From: Kris Coleman Date: Tue, 14 Oct 2025 00:28:38 -0400 Subject: [PATCH] feat: adds push command to cli Adds a new command to the CLI that allows users to push local flag configurations to a remote flag management service over HTTP/HTTPS. Signed-off-by: Kris Coleman --- .gitignore | 5 + Makefile | 71 +- api/v0/sync-codegen.yaml | 6 + api/v0/sync.yaml | 548 +++++++++++++ docs/commands/openfeature.md | 1 + docs/commands/openfeature_push.md | 67 ++ go.mod | 4 + go.sum | 13 + internal/api/client/sync_client.gen.go | 1032 ++++++++++++++++++++++++ internal/api/generate.go | 7 + internal/api/sync/client.go | 413 ++++++++++ internal/api/sync/retry_test.go | 426 ++++++++++ internal/cmd/pull.go | 19 +- internal/cmd/pull_test.go | 263 +++++- internal/cmd/push.go | 200 +++++ internal/cmd/push_test.go | 539 +++++++++++++ internal/cmd/root.go | 1 + internal/config/flags.go | 37 +- internal/flagset/flagset.go | 12 +- internal/manifest/manage.go | 62 +- internal/manifest/manage_test.go | 83 ++ schema/v0/README.md | 49 ++ 22 files changed, 3819 insertions(+), 39 deletions(-) create mode 100644 api/v0/sync-codegen.yaml create mode 100644 api/v0/sync.yaml create mode 100644 docs/commands/openfeature_push.md create mode 100644 internal/api/client/sync_client.gen.go create mode 100644 internal/api/generate.go create mode 100644 internal/api/sync/client.go create mode 100644 internal/api/sync/retry_test.go create mode 100644 internal/cmd/push.go create mode 100644 internal/cmd/push_test.go create mode 100644 internal/manifest/manage_test.go create mode 100644 schema/v0/README.md diff --git a/.gitignore b/.gitignore index 322cebf..5418a4c 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,8 @@ generated/ # generated files from running the CLI flags.json generated/ + +openfeature + +# Build output directory +/bin diff --git a/Makefile b/Makefile index 8876dfe..47902d9 100644 --- a/Makefile +++ b/Makefile @@ -2,14 +2,37 @@ help: @echo "Available commands:" @echo " help - Show this help message" + @echo " build - Build the CLI binary to bin/openfeature" + @echo " install - Install the CLI binary to system path" + @echo " lint - Run golangci-lint" + @echo " lint-fix - Run golangci-lint with auto-fix" @echo " test - Run unit tests" @echo " test-integration - Run all integration tests" @echo " test-integration-csharp - Run C# integration tests" @echo " test-integration-go - Run Go integration tests" @echo " test-integration-nodejs - Run NodeJS integration tests" + @echo " generate - Generate all code (API clients, docs, schema)" + @echo " generate-api - Generate API clients from OpenAPI specs" @echo " generate-docs - Generate documentation" @echo " generate-schema - Generate schema" + @echo " verify-generate - Check if generated files are up to date" @echo " fmt - Format Go code" + @echo " ci - Run all CI checks locally (lint, test, verify-generate)" + +.PHONY: build +build: + @echo "Building CLI binary..." + @mkdir -p bin + @go build -o bin/openfeature ./cmd/openfeature + @echo "CLI binary built successfully at bin/openfeature" + +.PHONY: install +install: build + @echo "Installing CLI binary..." + @GOPATH=$${GOPATH:-$$(go env GOPATH)}; \ + mkdir -p $$GOPATH/bin; \ + cp bin/openfeature $$GOPATH/bin/openfeature; \ + echo "CLI installed successfully to $$GOPATH/bin/openfeature" .PHONY: test test: @@ -48,8 +71,54 @@ generate-schema: @go run ./schema/generate-schema.go @echo "Schema generated successfully!" +.PHONY: generate-api +generate-api: + @echo "Generating API clients from OpenAPI specs..." + @go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest \ + --config api/v0/sync-codegen.yaml \ + api/v0/sync.yaml > internal/api/client/sync_client.gen.go + @echo "API clients generated successfully!" + +.PHONY: generate +generate: generate-api generate-docs generate-schema + @echo "All code generation completed successfully!" + .PHONY: fmt fmt: @echo "Running go fmt..." @go fmt ./... - @echo "Code formatted successfully!" \ No newline at end of file + @echo "Code formatted successfully!" + +.PHONY: lint +lint: + @echo "Running golangci-lint..." + @if ! command -v golangci-lint &> /dev/null; then \ + echo "Installing golangci-lint..."; \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0; \ + fi + @golangci-lint run ./... + @echo "Linting completed successfully!" + +.PHONY: lint-fix +lint-fix: + @echo "Running golangci-lint with auto-fix..." + @if ! command -v golangci-lint &> /dev/null; then \ + echo "Installing golangci-lint..."; \ + go install github.com/golangci/golangci-lint/cmd/golangci-lint@v1.64.0; \ + fi + @golangci-lint run --fix ./... + @echo "Linting with auto-fix completed successfully!" + +.PHONY: verify-generate +verify-generate: generate + @echo "Checking for uncommitted changes after generation..." + @if [ ! -z "$$(git status --porcelain)" ]; then \ + echo "Error: Generation produced diff. Please run 'make generate' and commit the results."; \ + git diff; \ + exit 1; \ + fi + @echo "All generated files are up to date!" + +.PHONY: ci +ci: lint test verify-generate + @echo "All CI checks passed successfully!" \ No newline at end of file diff --git a/api/v0/sync-codegen.yaml b/api/v0/sync-codegen.yaml new file mode 100644 index 0000000..bf63d94 --- /dev/null +++ b/api/v0/sync-codegen.yaml @@ -0,0 +1,6 @@ +# oapi-codegen configuration for sync API client generation +package: syncclient +generate: + - client + - models +output: internal/api/client/sync_client.gen.go \ No newline at end of file diff --git a/api/v0/sync.yaml b/api/v0/sync.yaml new file mode 100644 index 0000000..2c553d1 --- /dev/null +++ b/api/v0/sync.yaml @@ -0,0 +1,548 @@ +openapi: 3.0.3 +info: + title: Manifest Management API + version: 0.1.0 + description: | + CRUD endpoints that expose a manifest-friendly view of project flags for tooling such + as the OpenFeature CLI. The specification is provider-agnostic; replace the server + section with your deployment's base URL and token semantics. +tags: + - name: Manifest + description: Operations for reading and mutating manifest-friendly flag data. +servers: + - url: https://example.com + description: Replace with the provider base URL +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + schemas: + FlagDefaultValue: + description: Default value for a flag (can be boolean, string, integer, or object) + oneOf: + - type: boolean + - type: string + - type: integer + - type: object + ManifestFlag: + type: object + required: + - key + - type + - defaultValue + properties: + key: + type: string + description: Unique flag key within the flag set. + example: search-rollout + name: + type: string + description: Human-friendly flag name. Defaults to the key when omitted. + example: Search rollout + type: + type: string + description: Flag data type. + enum: [boolean, string, integer, object] + description: + type: string + nullable: true + description: Optional flag description. + example: Enable the new search experience. + defaultValue: + $ref: '#/components/schemas/FlagDefaultValue' + ManifestEnvelope: + type: object + required: + - flags + properties: + flags: + type: array + items: + $ref: '#/components/schemas/ManifestFlag' + ManifestFlagResponse: + type: object + required: + - flag + - updatedAt + properties: + flag: + $ref: '#/components/schemas/ManifestFlag' + updatedAt: + type: string + format: date-time + description: | + ISO timestamp reflecting the last update to the flag record. Clients can use this to + detect changes between manifest fetches or to implement optimistic concurrency checks. + example: 2024-03-02T09:45:03.000Z + ArchiveResponse: + type: object + required: + - message + - archivedAt + properties: + message: + type: string + example: Flag "search-rollout" archived. Restore it using your management interface if needed. + archivedAt: + type: string + format: date-time + nullable: true + description: Timestamp recording when the flag was archived. + ErrorDetail: + type: object + properties: + field: + type: string + description: Field path related to the error. + example: key + code: + type: string + description: Short machine-readable code (matches Zod issue codes). + example: invalid_type + message: + type: string + description: Human-friendly error message. + example: Flag exists but is archived. Restore it via the UI to reuse this key. + ErrorResponse: + type: object + required: + - error + properties: + error: + type: object + required: + - message + - status + properties: + message: + type: string + example: Flag type cannot be changed from boolean to string. + status: + type: integer + example: 409 + details: + type: array + items: + $ref: '#/components/schemas/ErrorDetail' +paths: + /openfeature/v0/manifest: + get: + tags: + - Manifest + summary: Get Project Manifest + description: | + Returns the project manifest containing active flags. Archived flags are excluded. + The response includes an `X-Manifest-Capabilities` header listing token capabilities. + security: + - bearerAuth: [] + responses: + '200': + description: Manifest exported successfully. + headers: + X-Manifest-Capabilities: + schema: + type: string + example: read,write,delete + description: Comma-separated list that reflects the token capabilities. + content: + application/json: + schema: + $ref: '#/components/schemas/ManifestEnvelope' + examples: + manifest: + summary: Sample manifest + value: + flags: + - key: search-rollout + name: Search rollout + type: boolean + description: Enable the new search experience. + defaultValue: false + - key: welcome-banner + name: Welcome banner + type: string + description: Localized welcome message + defaultValue: control + '401': + description: Missing or invalid token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unauthorized: + value: + error: + message: Authorization header required + status: 401 + '403': + description: Token lacks scope to read the manifest. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + forbidden: + value: + error: + message: This API token does not have read access. + status: 403 + '500': + description: Unexpected server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + serverError: + value: + error: + message: Unexpected error + status: 500 + /openfeature/v0/manifest/flags: + post: + tags: + - Manifest + summary: Create Manifest Flag + description: | + Creates a new flag exposed through the manifest. The request must be authenticated + with a token that includes `write` or `delete` capability. Attempting to create a + flag whose key already exists (active or archived) returns 409. + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key + - type + - defaultValue + properties: + key: + type: string + example: search-rollout + type: + type: string + enum: [boolean, string, integer, object] + name: + type: string + description: Optional display name. Defaults to the key. + example: Search rollout + description: + type: string + nullable: true + example: Enable the new search experience. + defaultValue: + $ref: '#/components/schemas/FlagDefaultValue' + additionalProperties: false + examples: + booleanFlag: + value: + key: search-rollout + type: boolean + name: Search rollout + description: Enable the new search experience. + defaultValue: false + responses: + '201': + description: Flag created successfully. + headers: + X-Manifest-Capabilities: + schema: + type: string + example: read,write,delete + content: + application/json: + schema: + $ref: '#/components/schemas/ManifestFlagResponse' + '400': + description: Validation error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + validation: + value: + error: + message: Validation failed. + status: 400 + details: + - field: key + code: too_small + message: Flag key is required. + '401': + description: Missing or invalid token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unauthorized: + value: + error: + message: Authorization header required + status: 401 + '403': + description: Token lacks write scope. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + forbidden: + value: + error: + message: This API token does not have write access. + status: 403 + '409': + description: Flag key already exists (active or archived). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + archivedConflict: + value: + error: + message: Flag with key "search-rollout" is archived. Restore it in the UI or choose a new key. + status: 409 + details: + - field: key + message: Flag exists but is archived. Restore it to reuse this key. + '500': + description: Unexpected server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /openfeature/v0/manifest/flags/{key}: + put: + tags: + - Manifest + summary: Update Manifest Flag + description: | + Replaces metadata for an existing manifest flag. The path parameter and body key must + match. Archived flags cannot be updated; they must be restored before modification. + security: + - bearerAuth: [] + parameters: + - name: key + in: path + required: true + schema: + type: string + description: Flag key to update. + requestBody: + required: true + content: + application/json: + schema: + type: object + required: + - key + - type + properties: + key: + type: string + example: search-rollout + type: + type: string + enum: [boolean, string, integer, object] + name: + type: string + example: Search rollout + description: + type: string + nullable: true + defaultValue: + $ref: '#/components/schemas/FlagDefaultValue' + additionalProperties: false + responses: + '200': + description: Flag updated successfully. + headers: + X-Manifest-Capabilities: + schema: + type: string + example: read,write,delete + content: + application/json: + schema: + $ref: '#/components/schemas/ManifestFlagResponse' + '400': + description: Validation failure (e.g., mismatched keys). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + mismatchedKey: + value: + error: + message: Flag key in path and payload must match. + status: 400 + details: + - field: key + message: Path key and payload key differ. + '401': + description: Missing or invalid token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unauthorized: + value: + error: + message: Authorization header required + status: 401 + '403': + description: Token lacks write scope. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + forbidden: + value: + error: + message: This API token does not have write access. + status: 403 + '404': + description: Flag not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + value: + error: + message: Flag "search-rollout" was not found. + status: 404 + '409': + description: Conflict (archived flag, immutable type). + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + archivedConflict: + value: + error: + message: Flag "search-rollout" is archived. Restore it in the UI before updating. + status: 409 + details: + - field: key + message: Archived flags cannot be updated via the manifest API. + typeConflict: + value: + error: + message: Flag type cannot be changed from boolean to string. + status: 409 + '500': + description: Unexpected server error. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + delete: + tags: + - Manifest + summary: Archive Manifest Flag + description: | + Removes a flag from the manifest. Providers may implement this as either a soft + delete (archive) or a hard delete; consult your provider documentation for specifics. + Flags that are active in protected environments may reject deletion requests depending + on provider policy. + security: + - bearerAuth: [] + parameters: + - name: key + in: path + required: true + schema: + type: string + description: Flag key to archive. + responses: + '200': + description: Flag archived successfully (soft delete). + headers: + X-Manifest-Capabilities: + schema: + type: string + example: read,write,delete + content: + application/json: + schema: + $ref: '#/components/schemas/ArchiveResponse' + examples: + archived: + value: + message: Flag "search-rollout" archived. Restore it using your management interface if needed. + archivedAt: 2024-03-02T10:01:22.000Z + '401': + description: Missing or invalid token. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + unauthorized: + value: + error: + message: Authorization header required + status: 401 + '403': + description: Token lacks delete scope. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + forbidden: + value: + error: + message: This API token does not have delete access. + status: 403 + '404': + description: Flag not found. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + notFound: + value: + error: + message: Flag "search-rollout" was not found. + status: 404 + '409': + description: Flag already archived or active in a protected environment. + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + examples: + alreadyArchived: + value: + error: + message: Flag "search-rollout" is already archived. + status: 409 + details: + - field: key + message: Flag is already archived. Restore it to use it again. + protectedEnvironment: + value: + error: + message: Flag is active in a protected environment. Disable it before archiving. + status: 409 + details: + - field: key + message: Disable the flag in protected environments before archiving. diff --git a/docs/commands/openfeature.md b/docs/commands/openfeature.md index c8f21cc..dd36a86 100644 --- a/docs/commands/openfeature.md +++ b/docs/commands/openfeature.md @@ -28,5 +28,6 @@ openfeature [flags] * [openfeature init](openfeature_init.md) - Initialize a new project * [openfeature manifest](openfeature_manifest.md) - Manage flag manifest files * [openfeature pull](openfeature_pull.md) - Pull a flag manifest from a remote source +* [openfeature push](openfeature_push.md) - Push flag configurations to a remote source * [openfeature version](openfeature_version.md) - Print the version number of the OpenFeature CLI diff --git a/docs/commands/openfeature_push.md b/docs/commands/openfeature_push.md new file mode 100644 index 0000000..a767c69 --- /dev/null +++ b/docs/commands/openfeature_push.md @@ -0,0 +1,67 @@ + + +## openfeature push + +Push flag configurations to a remote source + +### Synopsis + +The push command syncs local flag configurations to a remote flag management service. + +This command reads your local flag manifest and intelligently pushes it to a specified +remote destination. It performs a smart push by: + +1. Fetching existing flags from the remote +2. Comparing local flags with remote flags +3. Creating new flags that don't exist remotely +4. Updating existing flags that have changed + +This approach ensures idempotent operations and prevents conflicts. + +The pushed data follows the Manifest Management API OpenAPI specification defined at: +api/v0/sync.yaml + +The API uses individual flag endpoints: +- POST /openfeature/v0/manifest/flags - Creates new flags +- PUT /openfeature/v0/manifest/flags/{key} - Updates existing flags +- GET /openfeature/v0/manifest - Fetches existing flags for comparison + +Remote services implementing this API should accept the flag data in the format +specified by the OpenFeature flag manifest schema. + +Note: The file:// scheme is not supported for push operations. +For local file operations, use standard shell commands like cp or mv. + +``` +openfeature push [flags] +``` + +### Examples + +``` + # Push flags to a remote HTTPS endpoint (smart push: creates and updates as needed) + openfeature push --flag-source-url https://api.example.com --auth-token secret-token + + # Push flags to an HTTP endpoint (development) + openfeature push --flag-source-url http://localhost:8080 + + # Dry run to preview what would be sent + openfeature push --flag-source-url https://api.example.com --dry-run +``` + +### Options + +``` + --auth-token string The auth token for the flag destination + --debug Enable debug logging + --dry-run Preview changes without pushing + --flag-source-url string The URL of the flag destination + -h, --help help for push + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + +### SEE ALSO + +* [openfeature](openfeature.md) - CLI for OpenFeature. + diff --git a/go.mod b/go.mod index 2b676e4..ac6adee 100644 --- a/go.mod +++ b/go.mod @@ -29,6 +29,7 @@ require ( github.com/99designs/gqlgen v0.17.80 // indirect github.com/Khan/genqlient v0.8.1 // indirect github.com/adrg/xdg v0.5.3 // indirect + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect @@ -44,10 +45,13 @@ require ( github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kriscoleman/GoRetry v0.0.1 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oapi-codegen/runtime v1.1.2 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect diff --git a/go.sum b/go.sum index fc303e5..2ae1c98 100644 --- a/go.sum +++ b/go.sum @@ -21,13 +21,17 @@ github.com/MarvinJWendt/testza v0.3.0/go.mod h1:eFcL4I0idjtIx8P9C6KkAuLgATNKpX4/ github.com/MarvinJWendt/testza v0.4.2/go.mod h1:mSdhXiKH8sg/gQehJ63bINcCKp7RtYewEjXsvsVUPbE= github.com/MarvinJWendt/testza v0.5.2 h1:53KDo64C1z/h/d/stCYCPY69bt/OSwjq5KpFNwi+zB4= github.com/MarvinJWendt/testza v0.5.2/go.mod h1:xu53QFE5sCdjtMCKk8YMQ2MnymimEctc4n3EjyIYvEY= +github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= @@ -75,11 +79,13 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -87,6 +93,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kriscoleman/GoRetry v0.0.1 h1:mcslWckaAhHGplF0RyPPkcZx0sb6cJ6A+r5HIxsfecc= +github.com/kriscoleman/GoRetry v0.0.1/go.mod h1:G0FMRSlXtHuY4c7BK3KE5EEa3BANwPu/1Ye31q06+wE= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= @@ -98,6 +106,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32 h1:W6apQkHrMkS0Muv8G/TipAy/FJl/rCYT0+EuS8+Z0z4= github.com/nbio/st v0.0.0-20140626010706-e9e8d9816f32/go.mod h1:9wM+0iRr9ahx58uYLpLIr5fm8diHn0JbqRycJi6w0Ms= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oapi-codegen/runtime v1.1.2 h1:P2+CubHq8fO4Q6fV1tqDBZHCwpVpvPg7oKiYzQgXIyI= +github.com/oapi-codegen/runtime v1.1.2/go.mod h1:SK9X900oXmPWilYR5/WKPzt3Kqxn/uS/+lbpREv+eCg= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -138,6 +150,7 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4= github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/internal/api/client/sync_client.gen.go b/internal/api/client/sync_client.gen.go new file mode 100644 index 0000000..870fda4 --- /dev/null +++ b/internal/api/client/sync_client.gen.go @@ -0,0 +1,1032 @@ +// Package syncclient provides primitives to interact with the openapi HTTP API. +// +// Code generated by github.com/oapi-codegen/oapi-codegen/v2 version v2.5.1 DO NOT EDIT. +package syncclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/oapi-codegen/runtime" +) + +const ( + BearerAuthScopes = "bearerAuth.Scopes" +) + +// Defines values for ManifestFlagType. +const ( + ManifestFlagTypeBoolean ManifestFlagType = "boolean" + ManifestFlagTypeInteger ManifestFlagType = "integer" + ManifestFlagTypeObject ManifestFlagType = "object" + ManifestFlagTypeString ManifestFlagType = "string" +) + +// Defines values for PostOpenfeatureV0ManifestFlagsJSONBodyType. +const ( + PostOpenfeatureV0ManifestFlagsJSONBodyTypeBoolean PostOpenfeatureV0ManifestFlagsJSONBodyType = "boolean" + PostOpenfeatureV0ManifestFlagsJSONBodyTypeInteger PostOpenfeatureV0ManifestFlagsJSONBodyType = "integer" + PostOpenfeatureV0ManifestFlagsJSONBodyTypeObject PostOpenfeatureV0ManifestFlagsJSONBodyType = "object" + PostOpenfeatureV0ManifestFlagsJSONBodyTypeString PostOpenfeatureV0ManifestFlagsJSONBodyType = "string" +) + +// Defines values for PutOpenfeatureV0ManifestFlagsKeyJSONBodyType. +const ( + Boolean PutOpenfeatureV0ManifestFlagsKeyJSONBodyType = "boolean" + Integer PutOpenfeatureV0ManifestFlagsKeyJSONBodyType = "integer" + Object PutOpenfeatureV0ManifestFlagsKeyJSONBodyType = "object" + String PutOpenfeatureV0ManifestFlagsKeyJSONBodyType = "string" +) + +// ArchiveResponse defines model for ArchiveResponse. +type ArchiveResponse struct { + // ArchivedAt Timestamp recording when the flag was archived. + ArchivedAt *time.Time `json:"archivedAt"` + Message string `json:"message"` +} + +// ErrorDetail defines model for ErrorDetail. +type ErrorDetail struct { + // Code Short machine-readable code (matches Zod issue codes). + Code *string `json:"code,omitempty"` + + // Field Field path related to the error. + Field *string `json:"field,omitempty"` + + // Message Human-friendly error message. + Message *string `json:"message,omitempty"` +} + +// ErrorResponse defines model for ErrorResponse. +type ErrorResponse struct { + Error struct { + Details *[]ErrorDetail `json:"details,omitempty"` + Message string `json:"message"` + Status int `json:"status"` + } `json:"error"` +} + +// FlagDefaultValue Default value for a flag (can be boolean, string, integer, or object) +type FlagDefaultValue struct { + union json.RawMessage +} + +// FlagDefaultValue0 defines model for . +type FlagDefaultValue0 = bool + +// FlagDefaultValue1 defines model for . +type FlagDefaultValue1 = string + +// FlagDefaultValue2 defines model for . +type FlagDefaultValue2 = int + +// FlagDefaultValue3 defines model for . +type FlagDefaultValue3 = map[string]interface{} + +// ManifestEnvelope defines model for ManifestEnvelope. +type ManifestEnvelope struct { + Flags []ManifestFlag `json:"flags"` +} + +// ManifestFlag defines model for ManifestFlag. +type ManifestFlag struct { + // DefaultValue Default value for a flag (can be boolean, string, integer, or object) + DefaultValue FlagDefaultValue `json:"defaultValue"` + + // Description Optional flag description. + Description *string `json:"description"` + + // Key Unique flag key within the flag set. + Key string `json:"key"` + + // Name Human-friendly flag name. Defaults to the key when omitted. + Name *string `json:"name,omitempty"` + + // Type Flag data type. + Type ManifestFlagType `json:"type"` +} + +// ManifestFlagType Flag data type. +type ManifestFlagType string + +// ManifestFlagResponse defines model for ManifestFlagResponse. +type ManifestFlagResponse struct { + Flag ManifestFlag `json:"flag"` + + // UpdatedAt ISO timestamp reflecting the last update to the flag record. Clients can use this to + // detect changes between manifest fetches or to implement optimistic concurrency checks. + UpdatedAt time.Time `json:"updatedAt"` +} + +// PostOpenfeatureV0ManifestFlagsJSONBody defines parameters for PostOpenfeatureV0ManifestFlags. +type PostOpenfeatureV0ManifestFlagsJSONBody struct { + // DefaultValue Default value for a flag (can be boolean, string, integer, or object) + DefaultValue FlagDefaultValue `json:"defaultValue"` + Description *string `json:"description"` + Key string `json:"key"` + + // Name Optional display name. Defaults to the key. + Name *string `json:"name,omitempty"` + Type PostOpenfeatureV0ManifestFlagsJSONBodyType `json:"type"` +} + +// PostOpenfeatureV0ManifestFlagsJSONBodyType defines parameters for PostOpenfeatureV0ManifestFlags. +type PostOpenfeatureV0ManifestFlagsJSONBodyType string + +// PutOpenfeatureV0ManifestFlagsKeyJSONBody defines parameters for PutOpenfeatureV0ManifestFlagsKey. +type PutOpenfeatureV0ManifestFlagsKeyJSONBody struct { + // DefaultValue Default value for a flag (can be boolean, string, integer, or object) + DefaultValue *FlagDefaultValue `json:"defaultValue,omitempty"` + Description *string `json:"description"` + Key string `json:"key"` + Name *string `json:"name,omitempty"` + Type PutOpenfeatureV0ManifestFlagsKeyJSONBodyType `json:"type"` +} + +// PutOpenfeatureV0ManifestFlagsKeyJSONBodyType defines parameters for PutOpenfeatureV0ManifestFlagsKey. +type PutOpenfeatureV0ManifestFlagsKeyJSONBodyType string + +// PostOpenfeatureV0ManifestFlagsJSONRequestBody defines body for PostOpenfeatureV0ManifestFlags for application/json ContentType. +type PostOpenfeatureV0ManifestFlagsJSONRequestBody PostOpenfeatureV0ManifestFlagsJSONBody + +// PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody defines body for PutOpenfeatureV0ManifestFlagsKey for application/json ContentType. +type PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody PutOpenfeatureV0ManifestFlagsKeyJSONBody + +// AsFlagDefaultValue0 returns the union data inside the FlagDefaultValue as a FlagDefaultValue0 +func (t FlagDefaultValue) AsFlagDefaultValue0() (FlagDefaultValue0, error) { + var body FlagDefaultValue0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromFlagDefaultValue0 overwrites any union data inside the FlagDefaultValue as the provided FlagDefaultValue0 +func (t *FlagDefaultValue) FromFlagDefaultValue0(v FlagDefaultValue0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeFlagDefaultValue0 performs a merge with any union data inside the FlagDefaultValue, using the provided FlagDefaultValue0 +func (t *FlagDefaultValue) MergeFlagDefaultValue0(v FlagDefaultValue0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsFlagDefaultValue1 returns the union data inside the FlagDefaultValue as a FlagDefaultValue1 +func (t FlagDefaultValue) AsFlagDefaultValue1() (FlagDefaultValue1, error) { + var body FlagDefaultValue1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromFlagDefaultValue1 overwrites any union data inside the FlagDefaultValue as the provided FlagDefaultValue1 +func (t *FlagDefaultValue) FromFlagDefaultValue1(v FlagDefaultValue1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeFlagDefaultValue1 performs a merge with any union data inside the FlagDefaultValue, using the provided FlagDefaultValue1 +func (t *FlagDefaultValue) MergeFlagDefaultValue1(v FlagDefaultValue1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsFlagDefaultValue2 returns the union data inside the FlagDefaultValue as a FlagDefaultValue2 +func (t FlagDefaultValue) AsFlagDefaultValue2() (FlagDefaultValue2, error) { + var body FlagDefaultValue2 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromFlagDefaultValue2 overwrites any union data inside the FlagDefaultValue as the provided FlagDefaultValue2 +func (t *FlagDefaultValue) FromFlagDefaultValue2(v FlagDefaultValue2) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeFlagDefaultValue2 performs a merge with any union data inside the FlagDefaultValue, using the provided FlagDefaultValue2 +func (t *FlagDefaultValue) MergeFlagDefaultValue2(v FlagDefaultValue2) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsFlagDefaultValue3 returns the union data inside the FlagDefaultValue as a FlagDefaultValue3 +func (t FlagDefaultValue) AsFlagDefaultValue3() (FlagDefaultValue3, error) { + var body FlagDefaultValue3 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromFlagDefaultValue3 overwrites any union data inside the FlagDefaultValue as the provided FlagDefaultValue3 +func (t *FlagDefaultValue) FromFlagDefaultValue3(v FlagDefaultValue3) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeFlagDefaultValue3 performs a merge with any union data inside the FlagDefaultValue, using the provided FlagDefaultValue3 +func (t *FlagDefaultValue) MergeFlagDefaultValue3(v FlagDefaultValue3) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t FlagDefaultValue) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *FlagDefaultValue) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + +// RequestEditorFn is the function signature for the RequestEditor callback function +type RequestEditorFn func(ctx context.Context, req *http.Request) error + +// Doer performs HTTP requests. +// +// The standard http.Client implements this interface. +type HttpRequestDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// Client which conforms to the OpenAPI3 specification for this service. +type Client struct { + // The endpoint of the server conforming to this interface, with scheme, + // https://api.deepmap.com for example. This can contain a path relative + // to the server, such as https://api.deepmap.com/dev-test, and all the + // paths in the swagger spec will be appended to the server. + Server string + + // Doer for performing requests, typically a *http.Client with any + // customized settings, such as certificate chains. + Client HttpRequestDoer + + // A list of callbacks for modifying requests which are generated before sending over + // the network. + RequestEditors []RequestEditorFn +} + +// ClientOption allows setting custom parameters during construction +type ClientOption func(*Client) error + +// Creates a new Client, with reasonable defaults +func NewClient(server string, opts ...ClientOption) (*Client, error) { + // create a client with sane default values + client := Client{ + Server: server, + } + // mutate client and add all optional params + for _, o := range opts { + if err := o(&client); err != nil { + return nil, err + } + } + // ensure the server URL always has a trailing slash + if !strings.HasSuffix(client.Server, "/") { + client.Server += "/" + } + // create httpClient, if not already present + if client.Client == nil { + client.Client = &http.Client{} + } + return &client, nil +} + +// WithHTTPClient allows overriding the default Doer, which is +// automatically created using http.Client. This is useful for tests. +func WithHTTPClient(doer HttpRequestDoer) ClientOption { + return func(c *Client) error { + c.Client = doer + return nil + } +} + +// WithRequestEditorFn allows setting up a callback function, which will be +// called right before sending the request. This can be used to mutate the request. +func WithRequestEditorFn(fn RequestEditorFn) ClientOption { + return func(c *Client) error { + c.RequestEditors = append(c.RequestEditors, fn) + return nil + } +} + +// The interface specification for the client above. +type ClientInterface interface { + // GetOpenfeatureV0Manifest request + GetOpenfeatureV0Manifest(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PostOpenfeatureV0ManifestFlagsWithBody request with any body + PostOpenfeatureV0ManifestFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PostOpenfeatureV0ManifestFlags(ctx context.Context, body PostOpenfeatureV0ManifestFlagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) + + // DeleteOpenfeatureV0ManifestFlagsKey request + DeleteOpenfeatureV0ManifestFlagsKey(ctx context.Context, key string, reqEditors ...RequestEditorFn) (*http.Response, error) + + // PutOpenfeatureV0ManifestFlagsKeyWithBody request with any body + PutOpenfeatureV0ManifestFlagsKeyWithBody(ctx context.Context, key string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) + + PutOpenfeatureV0ManifestFlagsKey(ctx context.Context, key string, body PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) +} + +func (c *Client) GetOpenfeatureV0Manifest(ctx context.Context, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewGetOpenfeatureV0ManifestRequest(c.Server) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostOpenfeatureV0ManifestFlagsWithBody(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostOpenfeatureV0ManifestFlagsRequestWithBody(c.Server, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PostOpenfeatureV0ManifestFlags(ctx context.Context, body PostOpenfeatureV0ManifestFlagsJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPostOpenfeatureV0ManifestFlagsRequest(c.Server, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) DeleteOpenfeatureV0ManifestFlagsKey(ctx context.Context, key string, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewDeleteOpenfeatureV0ManifestFlagsKeyRequest(c.Server, key) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PutOpenfeatureV0ManifestFlagsKeyWithBody(ctx context.Context, key string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutOpenfeatureV0ManifestFlagsKeyRequestWithBody(c.Server, key, contentType, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +func (c *Client) PutOpenfeatureV0ManifestFlagsKey(ctx context.Context, key string, body PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*http.Response, error) { + req, err := NewPutOpenfeatureV0ManifestFlagsKeyRequest(c.Server, key, body) + if err != nil { + return nil, err + } + req = req.WithContext(ctx) + if err := c.applyEditors(ctx, req, reqEditors); err != nil { + return nil, err + } + return c.Client.Do(req) +} + +// NewGetOpenfeatureV0ManifestRequest generates requests for GetOpenfeatureV0Manifest +func NewGetOpenfeatureV0ManifestRequest(server string) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openfeature/v0/manifest") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("GET", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPostOpenfeatureV0ManifestFlagsRequest calls the generic PostOpenfeatureV0ManifestFlags builder with application/json body +func NewPostOpenfeatureV0ManifestFlagsRequest(server string, body PostOpenfeatureV0ManifestFlagsJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPostOpenfeatureV0ManifestFlagsRequestWithBody(server, "application/json", bodyReader) +} + +// NewPostOpenfeatureV0ManifestFlagsRequestWithBody generates requests for PostOpenfeatureV0ManifestFlags with any type of body +func NewPostOpenfeatureV0ManifestFlagsRequestWithBody(server string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openfeature/v0/manifest/flags") + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("POST", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +// NewDeleteOpenfeatureV0ManifestFlagsKeyRequest generates requests for DeleteOpenfeatureV0ManifestFlagsKey +func NewDeleteOpenfeatureV0ManifestFlagsKeyRequest(server string, key string) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "key", runtime.ParamLocationPath, key) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openfeature/v0/manifest/flags/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("DELETE", queryURL.String(), nil) + if err != nil { + return nil, err + } + + return req, nil +} + +// NewPutOpenfeatureV0ManifestFlagsKeyRequest calls the generic PutOpenfeatureV0ManifestFlagsKey builder with application/json body +func NewPutOpenfeatureV0ManifestFlagsKeyRequest(server string, key string, body PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody) (*http.Request, error) { + var bodyReader io.Reader + buf, err := json.Marshal(body) + if err != nil { + return nil, err + } + bodyReader = bytes.NewReader(buf) + return NewPutOpenfeatureV0ManifestFlagsKeyRequestWithBody(server, key, "application/json", bodyReader) +} + +// NewPutOpenfeatureV0ManifestFlagsKeyRequestWithBody generates requests for PutOpenfeatureV0ManifestFlagsKey with any type of body +func NewPutOpenfeatureV0ManifestFlagsKeyRequestWithBody(server string, key string, contentType string, body io.Reader) (*http.Request, error) { + var err error + + var pathParam0 string + + pathParam0, err = runtime.StyleParamWithLocation("simple", false, "key", runtime.ParamLocationPath, key) + if err != nil { + return nil, err + } + + serverURL, err := url.Parse(server) + if err != nil { + return nil, err + } + + operationPath := fmt.Sprintf("/openfeature/v0/manifest/flags/%s", pathParam0) + if operationPath[0] == '/' { + operationPath = "." + operationPath + } + + queryURL, err := serverURL.Parse(operationPath) + if err != nil { + return nil, err + } + + req, err := http.NewRequest("PUT", queryURL.String(), body) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", contentType) + + return req, nil +} + +func (c *Client) applyEditors(ctx context.Context, req *http.Request, additionalEditors []RequestEditorFn) error { + for _, r := range c.RequestEditors { + if err := r(ctx, req); err != nil { + return err + } + } + for _, r := range additionalEditors { + if err := r(ctx, req); err != nil { + return err + } + } + return nil +} + +// ClientWithResponses builds on ClientInterface to offer response payloads +type ClientWithResponses struct { + ClientInterface +} + +// NewClientWithResponses creates a new ClientWithResponses, which wraps +// Client with return type handling +func NewClientWithResponses(server string, opts ...ClientOption) (*ClientWithResponses, error) { + client, err := NewClient(server, opts...) + if err != nil { + return nil, err + } + return &ClientWithResponses{client}, nil +} + +// WithBaseURL overrides the baseURL. +func WithBaseURL(baseURL string) ClientOption { + return func(c *Client) error { + newBaseURL, err := url.Parse(baseURL) + if err != nil { + return err + } + c.Server = newBaseURL.String() + return nil + } +} + +// ClientWithResponsesInterface is the interface specification for the client with responses above. +type ClientWithResponsesInterface interface { + // GetOpenfeatureV0ManifestWithResponse request + GetOpenfeatureV0ManifestWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOpenfeatureV0ManifestResponse, error) + + // PostOpenfeatureV0ManifestFlagsWithBodyWithResponse request with any body + PostOpenfeatureV0ManifestFlagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostOpenfeatureV0ManifestFlagsResponse, error) + + PostOpenfeatureV0ManifestFlagsWithResponse(ctx context.Context, body PostOpenfeatureV0ManifestFlagsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostOpenfeatureV0ManifestFlagsResponse, error) + + // DeleteOpenfeatureV0ManifestFlagsKeyWithResponse request + DeleteOpenfeatureV0ManifestFlagsKeyWithResponse(ctx context.Context, key string, reqEditors ...RequestEditorFn) (*DeleteOpenfeatureV0ManifestFlagsKeyResponse, error) + + // PutOpenfeatureV0ManifestFlagsKeyWithBodyWithResponse request with any body + PutOpenfeatureV0ManifestFlagsKeyWithBodyWithResponse(ctx context.Context, key string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutOpenfeatureV0ManifestFlagsKeyResponse, error) + + PutOpenfeatureV0ManifestFlagsKeyWithResponse(ctx context.Context, key string, body PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutOpenfeatureV0ManifestFlagsKeyResponse, error) +} + +type GetOpenfeatureV0ManifestResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ManifestEnvelope + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r GetOpenfeatureV0ManifestResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r GetOpenfeatureV0ManifestResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PostOpenfeatureV0ManifestFlagsResponse struct { + Body []byte + HTTPResponse *http.Response + JSON201 *ManifestFlagResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON409 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PostOpenfeatureV0ManifestFlagsResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PostOpenfeatureV0ManifestFlagsResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type DeleteOpenfeatureV0ManifestFlagsKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ArchiveResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON409 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r DeleteOpenfeatureV0ManifestFlagsKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r DeleteOpenfeatureV0ManifestFlagsKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +type PutOpenfeatureV0ManifestFlagsKeyResponse struct { + Body []byte + HTTPResponse *http.Response + JSON200 *ManifestFlagResponse + JSON400 *ErrorResponse + JSON401 *ErrorResponse + JSON403 *ErrorResponse + JSON404 *ErrorResponse + JSON409 *ErrorResponse + JSON500 *ErrorResponse +} + +// Status returns HTTPResponse.Status +func (r PutOpenfeatureV0ManifestFlagsKeyResponse) Status() string { + if r.HTTPResponse != nil { + return r.HTTPResponse.Status + } + return http.StatusText(0) +} + +// StatusCode returns HTTPResponse.StatusCode +func (r PutOpenfeatureV0ManifestFlagsKeyResponse) StatusCode() int { + if r.HTTPResponse != nil { + return r.HTTPResponse.StatusCode + } + return 0 +} + +// GetOpenfeatureV0ManifestWithResponse request returning *GetOpenfeatureV0ManifestResponse +func (c *ClientWithResponses) GetOpenfeatureV0ManifestWithResponse(ctx context.Context, reqEditors ...RequestEditorFn) (*GetOpenfeatureV0ManifestResponse, error) { + rsp, err := c.GetOpenfeatureV0Manifest(ctx, reqEditors...) + if err != nil { + return nil, err + } + return ParseGetOpenfeatureV0ManifestResponse(rsp) +} + +// PostOpenfeatureV0ManifestFlagsWithBodyWithResponse request with arbitrary body returning *PostOpenfeatureV0ManifestFlagsResponse +func (c *ClientWithResponses) PostOpenfeatureV0ManifestFlagsWithBodyWithResponse(ctx context.Context, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PostOpenfeatureV0ManifestFlagsResponse, error) { + rsp, err := c.PostOpenfeatureV0ManifestFlagsWithBody(ctx, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostOpenfeatureV0ManifestFlagsResponse(rsp) +} + +func (c *ClientWithResponses) PostOpenfeatureV0ManifestFlagsWithResponse(ctx context.Context, body PostOpenfeatureV0ManifestFlagsJSONRequestBody, reqEditors ...RequestEditorFn) (*PostOpenfeatureV0ManifestFlagsResponse, error) { + rsp, err := c.PostOpenfeatureV0ManifestFlags(ctx, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePostOpenfeatureV0ManifestFlagsResponse(rsp) +} + +// DeleteOpenfeatureV0ManifestFlagsKeyWithResponse request returning *DeleteOpenfeatureV0ManifestFlagsKeyResponse +func (c *ClientWithResponses) DeleteOpenfeatureV0ManifestFlagsKeyWithResponse(ctx context.Context, key string, reqEditors ...RequestEditorFn) (*DeleteOpenfeatureV0ManifestFlagsKeyResponse, error) { + rsp, err := c.DeleteOpenfeatureV0ManifestFlagsKey(ctx, key, reqEditors...) + if err != nil { + return nil, err + } + return ParseDeleteOpenfeatureV0ManifestFlagsKeyResponse(rsp) +} + +// PutOpenfeatureV0ManifestFlagsKeyWithBodyWithResponse request with arbitrary body returning *PutOpenfeatureV0ManifestFlagsKeyResponse +func (c *ClientWithResponses) PutOpenfeatureV0ManifestFlagsKeyWithBodyWithResponse(ctx context.Context, key string, contentType string, body io.Reader, reqEditors ...RequestEditorFn) (*PutOpenfeatureV0ManifestFlagsKeyResponse, error) { + rsp, err := c.PutOpenfeatureV0ManifestFlagsKeyWithBody(ctx, key, contentType, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutOpenfeatureV0ManifestFlagsKeyResponse(rsp) +} + +func (c *ClientWithResponses) PutOpenfeatureV0ManifestFlagsKeyWithResponse(ctx context.Context, key string, body PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody, reqEditors ...RequestEditorFn) (*PutOpenfeatureV0ManifestFlagsKeyResponse, error) { + rsp, err := c.PutOpenfeatureV0ManifestFlagsKey(ctx, key, body, reqEditors...) + if err != nil { + return nil, err + } + return ParsePutOpenfeatureV0ManifestFlagsKeyResponse(rsp) +} + +// ParseGetOpenfeatureV0ManifestResponse parses an HTTP response from a GetOpenfeatureV0ManifestWithResponse call +func ParseGetOpenfeatureV0ManifestResponse(rsp *http.Response) (*GetOpenfeatureV0ManifestResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &GetOpenfeatureV0ManifestResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ManifestEnvelope + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParsePostOpenfeatureV0ManifestFlagsResponse parses an HTTP response from a PostOpenfeatureV0ManifestFlagsWithResponse call +func ParsePostOpenfeatureV0ManifestFlagsResponse(rsp *http.Response) (*PostOpenfeatureV0ManifestFlagsResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PostOpenfeatureV0ManifestFlagsResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 201: + var dest ManifestFlagResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON201 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} + +// ParseDeleteOpenfeatureV0ManifestFlagsKeyResponse parses an HTTP response from a DeleteOpenfeatureV0ManifestFlagsKeyWithResponse call +func ParseDeleteOpenfeatureV0ManifestFlagsKeyResponse(rsp *http.Response) (*DeleteOpenfeatureV0ManifestFlagsKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &DeleteOpenfeatureV0ManifestFlagsKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ArchiveResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + } + + return response, nil +} + +// ParsePutOpenfeatureV0ManifestFlagsKeyResponse parses an HTTP response from a PutOpenfeatureV0ManifestFlagsKeyWithResponse call +func ParsePutOpenfeatureV0ManifestFlagsKeyResponse(rsp *http.Response) (*PutOpenfeatureV0ManifestFlagsKeyResponse, error) { + bodyBytes, err := io.ReadAll(rsp.Body) + defer func() { _ = rsp.Body.Close() }() + if err != nil { + return nil, err + } + + response := &PutOpenfeatureV0ManifestFlagsKeyResponse{ + Body: bodyBytes, + HTTPResponse: rsp, + } + + switch { + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: + var dest ManifestFlagResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON200 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 400: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON400 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 401: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON401 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 403: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON403 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 404: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON404 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 409: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON409 = &dest + + case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 500: + var dest ErrorResponse + if err := json.Unmarshal(bodyBytes, &dest); err != nil { + return nil, err + } + response.JSON500 = &dest + + } + + return response, nil +} diff --git a/internal/api/generate.go b/internal/api/generate.go new file mode 100644 index 0000000..cf912eb --- /dev/null +++ b/internal/api/generate.go @@ -0,0 +1,7 @@ +// Package api provides generated API clients for OpenFeature CLI +package api + +// Generate API clients from OpenAPI specifications +// Run: go generate ./internal/api/... + +//go:generate go run github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@latest --config=../../api/v0/sync-codegen.yaml ../../api/v0/sync.yaml -o client/sync_client.gen.go diff --git a/internal/api/sync/client.go b/internal/api/sync/client.go new file mode 100644 index 0000000..965bcad --- /dev/null +++ b/internal/api/sync/client.go @@ -0,0 +1,413 @@ +// Package sync provides a wrapper around the generated OpenAPI client for sync operations +package sync + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "time" + + goretry "github.com/kriscoleman/GoRetry" + syncclient "github.com/open-feature/cli/internal/api/client" + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/logger" +) + +// Client wraps the generated OpenAPI client with convenience methods +type Client struct { + apiClient *syncclient.ClientWithResponses + authToken string +} + +// httpError wraps an HTTP response status code for retry logic +type httpError struct { + statusCode int + message string +} + +func (e *httpError) Error() string { + return e.message +} + +// isTransientHTTPError determines if an error should trigger a retry. +// Returns true for: +// - 5xx server errors (transient) +// - Network errors (timeouts, temporary failures) +// Returns false for: +// - 4xx client errors (permanent) +// - Successful responses (2xx, 3xx) +func isTransientHTTPError(err error) bool { + if err == nil { + return false + } + + // Check if it's an HTTP error with a status code + var httpErr *httpError + if errors.As(err, &httpErr) { + // Retry on 5xx server errors + if httpErr.statusCode >= 500 && httpErr.statusCode < 600 { + return true + } + // Don't retry on 4xx client errors or successful responses + return false + } + + // For non-HTTP errors, use default transient error detection + // This catches network errors, timeouts, etc. + return goretry.DefaultTransientErrorFunc(err) +} + +// NewClient creates a new sync client +func NewClient(baseURL string, authToken string) (*Client, error) { + // Create a custom HTTP client with timeout + httpClient := &http.Client{ + Timeout: 30 * time.Second, + } + + // Add authentication if provided + var opts []syncclient.ClientOption + opts = append(opts, syncclient.WithHTTPClient(httpClient)) + + if authToken != "" { + opts = append(opts, syncclient.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) + return nil + })) + } + + // Add standard headers + opts = append(opts, syncclient.WithRequestEditorFn(func(ctx context.Context, req *http.Request) error { + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "openfeature-cli/sync") + return nil + })) + + apiClient, err := syncclient.NewClientWithResponses(baseURL, opts...) + if err != nil { + return nil, fmt.Errorf("failed to create API client: %w", err) + } + + return &Client{ + apiClient: apiClient, + authToken: authToken, + }, nil +} + +// PushResult contains the results of a push operation +type PushResult struct { + Created []flagset.Flag + Updated []flagset.Flag + Unchanged []flagset.Flag +} + +// PullFlags fetches flags from the remote API +func (c *Client) PullFlags(ctx context.Context) (*flagset.Flagset, error) { + logger.Default.Debug("Fetching flags using sync API client") + + resp, err := c.apiClient.GetOpenfeatureV0ManifestWithResponse(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch manifest: %w", err) + } + + // Debug: log HTTP response details + if resp.HTTPResponse != nil { + logger.Default.Debug(fmt.Sprintf("Pull response: HTTP %d - %s", resp.HTTPResponse.StatusCode, resp.HTTPResponse.Status)) + if len(resp.Body) > 0 { + logger.Default.Debug(fmt.Sprintf("Response body: %s", string(resp.Body))) + } + } + + // Check for successful status code + if resp.HTTPResponse == nil { + return nil, fmt.Errorf("received nil HTTP response") + } + + if resp.HTTPResponse.StatusCode < 200 || resp.HTTPResponse.StatusCode >= 300 { + // Try to parse error response + if resp.JSON401 != nil { + return nil, fmt.Errorf("authentication failed: %s", resp.JSON401.Error.Message) + } else if resp.JSON403 != nil { + return nil, fmt.Errorf("authorization failed: %s", resp.JSON403.Error.Message) + } else if resp.JSON500 != nil { + return nil, fmt.Errorf("server error: %s", resp.JSON500.Error.Message) + } + return nil, fmt.Errorf("unexpected status code %d: %s", resp.HTTPResponse.StatusCode, string(resp.Body)) + } + + // Parse successful response + if resp.JSON200 == nil { + return nil, fmt.Errorf("expected manifest data but got none") + } + + // Convert from API model to internal flagset model + flags := make([]flagset.Flag, 0, len(resp.JSON200.Flags)) + for _, apiFlag := range resp.JSON200.Flags { + // Parse flag type from string + flagType, err := flagset.ParseFlagType(string(apiFlag.Type)) + if err != nil { + return nil, fmt.Errorf("failed to parse flag type for %s: %w", apiFlag.Key, err) + } + + flag := flagset.Flag{ + Key: apiFlag.Key, + Type: flagType, + } + + // Set optional fields + if apiFlag.Description != nil { + flag.Description = *apiFlag.Description + } + + // Convert defaultValue from union type by marshaling and unmarshaling + defaultValueJSON, err := json.Marshal(apiFlag.DefaultValue) + if err != nil { + return nil, fmt.Errorf("failed to marshal defaultValue for flag %s: %w", flag.Key, err) + } + if err := json.Unmarshal(defaultValueJSON, &flag.DefaultValue); err != nil { + return nil, fmt.Errorf("failed to parse defaultValue for flag %s: %w", flag.Key, err) + } + + flags = append(flags, flag) + } + + logger.Default.Debug(fmt.Sprintf("Successfully pulled %d flags", len(flags))) + + return &flagset.Flagset{Flags: flags}, nil +} + +// PushFlags fetches remote flags, compares with local flags, and intelligently +// creates or updates flags as needed. Returns a PushResult with details of what was changed. +// If dryRun is true, only performs the comparison without making actual API calls. +func (c *Client) PushFlags(ctx context.Context, localFlags *flagset.Flagset, remoteFlags *flagset.Flagset, dryRun bool) (*PushResult, error) { + // Build a map of remote flags for quick lookup + remoteFlagMap := make(map[string]flagset.Flag) + for _, flag := range remoteFlags.Flags { + remoteFlagMap[flag.Key] = flag + } + + var toCreate []flagset.Flag + var toUpdate []flagset.Flag + + // Determine which flags need to be created vs updated + for _, localFlag := range localFlags.Flags { + if remoteFlag, exists := remoteFlagMap[localFlag.Key]; exists { + // Only update if the flag has actually changed + if !flagsEqual(localFlag, remoteFlag) { + toUpdate = append(toUpdate, localFlag) + } + } else { + toCreate = append(toCreate, localFlag) + } + } + + result := &PushResult{} + + // If dry run, skip actual API calls and just return what would be done + if dryRun { + result.Created = toCreate + result.Updated = toUpdate + return result, nil + } + + // Create new flags with retry logic + for _, flag := range toCreate { + flagKey := flag.Key // Capture for closure + err := goretry.IfNeededWithContext(ctx, func(ctx context.Context) error { + body, err := c.convertFlagToAPIBody(flag) + if err != nil { + return fmt.Errorf("failed to convert flag %s: %w", flagKey, err) + } + + // Debug: log what we're sending + if logger.Default.IsDebugEnabled() { + bodyJSON, _ := json.MarshalIndent(body, "", " ") + logger.Default.Debug(fmt.Sprintf("Sending POST for %s:\n%s", flagKey, string(bodyJSON))) + } + + resp, err := c.apiClient.PostOpenfeatureV0ManifestFlagsWithResponse(ctx, body) + if err != nil { + return fmt.Errorf("failed to create flag %s: %w", flagKey, err) + } + + // Debug: log server response + if logger.Default.IsDebugEnabled() { + logger.Default.Debug(fmt.Sprintf("Server response for %s:\n%s", flagKey, string(resp.Body))) + } + + return c.handleFlagResponse(resp.HTTPResponse, resp.Body, flagKey, "create") + }, goretry.WithTransientErrorFunc(isTransientHTTPError)) + + if err != nil { + return nil, err + } + result.Created = append(result.Created, flag) + } + + // Update existing flags with retry logic + for _, flag := range toUpdate { + flagKey := flag.Key // Capture for closure + err := goretry.IfNeededWithContext(ctx, func(ctx context.Context) error { + body, err := c.convertFlagToPutBody(flag) + if err != nil { + return fmt.Errorf("failed to convert flag %s: %w", flagKey, err) + } + + // Debug: log what we're sending + if logger.Default.IsDebugEnabled() { + bodyJSON, _ := json.MarshalIndent(body, "", " ") + logger.Default.Debug(fmt.Sprintf("Sending PUT for %s:\n%s", flagKey, string(bodyJSON))) + } + + resp, err := c.apiClient.PutOpenfeatureV0ManifestFlagsKeyWithResponse(ctx, flagKey, body) + if err != nil { + return fmt.Errorf("failed to update flag %s: %w", flagKey, err) + } + + // Debug: log server response + if logger.Default.IsDebugEnabled() { + logger.Default.Debug(fmt.Sprintf("Server response for %s:\n%s", flagKey, string(resp.Body))) + } + + return c.handleFlagResponse(resp.HTTPResponse, resp.Body, flagKey, "update") + }, goretry.WithTransientErrorFunc(isTransientHTTPError)) + + if err != nil { + return nil, err + } + result.Updated = append(result.Updated, flag) + } + + return result, nil +} + +// convertFlagToAPIBody converts internal flag to POST API body format +func (c *Client) convertFlagToAPIBody(flag flagset.Flag) (syncclient.PostOpenfeatureV0ManifestFlagsJSONRequestBody, error) { + // Convert flag type to API enum + flagType := syncclient.PostOpenfeatureV0ManifestFlagsJSONBodyType(flag.Type.String()) + + // Marshal and unmarshal the defaultValue through JSON to properly set the union type + defaultValueJSON, err := json.Marshal(flag.DefaultValue) + if err != nil { + return syncclient.PostOpenfeatureV0ManifestFlagsJSONRequestBody{}, fmt.Errorf("failed to marshal defaultValue: %w", err) + } + + var defaultValue syncclient.FlagDefaultValue + if err := json.Unmarshal(defaultValueJSON, &defaultValue); err != nil { + return syncclient.PostOpenfeatureV0ManifestFlagsJSONRequestBody{}, fmt.Errorf("failed to unmarshal defaultValue: %w", err) + } + + // Create the request body + body := syncclient.PostOpenfeatureV0ManifestFlagsJSONRequestBody{ + Key: flag.Key, + Type: flagType, + DefaultValue: defaultValue, + } + + // Add description if present + if flag.Description != "" { + body.Description = &flag.Description + } + + return body, nil +} + +// convertFlagToPutBody converts internal flag to PUT API body format +func (c *Client) convertFlagToPutBody(flag flagset.Flag) (syncclient.PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody, error) { + // Convert flag type to API enum + flagType := syncclient.PutOpenfeatureV0ManifestFlagsKeyJSONBodyType(flag.Type.String()) + + // Marshal and unmarshal the defaultValue through JSON to properly set the union type + defaultValueJSON, err := json.Marshal(flag.DefaultValue) + if err != nil { + return syncclient.PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody{}, fmt.Errorf("failed to marshal defaultValue: %w", err) + } + + var defaultValue syncclient.FlagDefaultValue + if err := json.Unmarshal(defaultValueJSON, &defaultValue); err != nil { + return syncclient.PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody{}, fmt.Errorf("failed to unmarshal defaultValue: %w", err) + } + + // Create the request body + body := syncclient.PutOpenfeatureV0ManifestFlagsKeyJSONRequestBody{ + Key: flag.Key, + Type: flagType, + DefaultValue: &defaultValue, + } + + // Add description if present + if flag.Description != "" { + body.Description = &flag.Description + } + + return body, nil +} + +// handleFlagResponse processes the HTTP response for individual flag operations +func (c *Client) handleFlagResponse(resp *http.Response, body []byte, flagKey string, operation string) error { + if resp == nil { + return fmt.Errorf("received nil response for flag %s", flagKey) + } + + // Debug: log HTTP response details + logger.Default.Debug(fmt.Sprintf("%s flag %s: HTTP %d - %s", operation, flagKey, resp.StatusCode, resp.Status)) + if len(body) > 0 { + logger.Default.Debug(fmt.Sprintf("Response body: %s", string(body))) + } + + // Check for successful status code + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + + // Build error message + var message string + // Try to parse error response for better error messages + var errorResp syncclient.ErrorResponse + if err := json.Unmarshal(body, &errorResp); err == nil { + message = fmt.Sprintf("failed to %s flag %s (status %d): %s", operation, flagKey, resp.StatusCode, errorResp.Error.Message) + } else { + // Fallback to raw response + message = fmt.Sprintf("failed to %s flag %s (status %d): %s", operation, flagKey, resp.StatusCode, string(body)) + } + + // Return httpError so retry logic can determine if it's transient + return &httpError{ + statusCode: resp.StatusCode, + message: message, + } +} + +// flagsEqual compares two flags to determine if they are effectively identical +func flagsEqual(a, b flagset.Flag) bool { + // Compare key, type, and defaultValue + if a.Key != b.Key || a.Type != b.Type { + return false + } + + // Marshal both defaultValues to JSON for comparison + aJSON, err := json.Marshal(a.DefaultValue) + if err != nil { + return false + } + + bJSON, err := json.Marshal(b.DefaultValue) + if err != nil { + return false + } + + // Compare JSON representations + if string(aJSON) != string(bJSON) { + logger.Default.Debug(fmt.Sprintf("Flag %s differs:\n Local: %s\n Remote: %s", a.Key, string(aJSON), string(bJSON))) + return false + } + + // Compare descriptions (both empty or identical) + if a.Description != b.Description { + logger.Default.Debug(fmt.Sprintf("Flag %s description differs:\n Local: %q\n Remote: %q", a.Key, a.Description, b.Description)) + return false + } + + return true +} diff --git a/internal/api/sync/retry_test.go b/internal/api/sync/retry_test.go new file mode 100644 index 0000000..36302a5 --- /dev/null +++ b/internal/api/sync/retry_test.go @@ -0,0 +1,426 @@ +package sync + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/open-feature/cli/internal/flagset" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRetryLogic(t *testing.T) { + t.Run("retries on 5xx errors and eventually succeeds", func(t *testing.T) { + defer gock.Off() + + // First attempt: 500 error + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Reply(500). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Internal Server Error", + "status": 500, + }, + }) + + // Second attempt: 500 error + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Reply(500). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Internal Server Error", + "status": 500, + }, + }) + + // Third attempt: success + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": "test-flag", + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "test-flag", Type: flagset.BoolType, DefaultValue: true}, + }, + } + remoteFlags := &flagset.Flagset{Flags: []flagset.Flag{}} + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.NoError(t, err, "Should succeed after retries") + assert.True(t, gock.IsDone(), "All expected requests should be made") + }) + + t.Run("does not retry on 4xx errors", func(t *testing.T) { + defer gock.Off() + + // Track the number of attempts + attemptCount := 0 + + // Mock the POST request to fail with 400 (bad request) + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + attemptCount++ + return true, nil + }). + Reply(400). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Bad Request", + "status": 400, + }, + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "test-flag", Type: flagset.BoolType, DefaultValue: true}, + }, + } + remoteFlags := &flagset.Flagset{Flags: []flagset.Flag{}} + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.Error(t, err, "Should fail with 400 error") + assert.Contains(t, err.Error(), "400") + assert.Equal(t, 1, attemptCount, "Should only attempt once (no retries for 4xx)") + }) + + t.Run("exhausts retries on persistent 5xx errors", func(t *testing.T) { + defer gock.Off() + + // Track the number of attempts + attemptCount := 0 + + // Mock the POST request to always fail with 503 + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Times(3). // Default max attempts + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + attemptCount++ + return true, nil + }). + Reply(503). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Service Unavailable", + "status": 503, + }, + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "test-flag", Type: flagset.BoolType, DefaultValue: true}, + }, + } + remoteFlags := &flagset.Flagset{Flags: []flagset.Flag{}} + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.Error(t, err, "Should fail after exhausting retries") + assert.Contains(t, err.Error(), "503") + assert.Equal(t, 3, attemptCount, "Should attempt 3 times (max attempts)") + }) + + t.Run("retries PUT operations on 5xx errors", func(t *testing.T) { + defer gock.Off() + + // First attempt: 502 error + gock.New("https://api.example.com"). + Put("/openfeature/v0/manifest/flags/existing-flag"). + Reply(502). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Bad Gateway", + "status": 502, + }, + }) + + // Second attempt: success + gock.New("https://api.example.com"). + Put("/openfeature/v0/manifest/flags/existing-flag"). + Reply(200). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": "existing-flag", + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "existing-flag", Type: flagset.BoolType, DefaultValue: true}, + }, + } + remoteFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "existing-flag", Type: flagset.BoolType, DefaultValue: false}, + }, + } + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.NoError(t, err, "Should succeed after retry") + assert.True(t, gock.IsDone(), "All expected requests should be made") + }) + + t.Run("does not retry PUT operations on 404 errors", func(t *testing.T) { + defer gock.Off() + + // Track the number of attempts + attemptCount := 0 + + // Mock the PUT request to fail with 404 (not found) + gock.New("https://api.example.com"). + Put("/openfeature/v0/manifest/flags/nonexistent-flag"). + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + attemptCount++ + return true, nil + }). + Reply(404). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Flag not found", + "status": 404, + }, + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "nonexistent-flag", Type: flagset.BoolType, DefaultValue: true}, + }, + } + remoteFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "nonexistent-flag", Type: flagset.BoolType, DefaultValue: false}, + }, + } + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.Error(t, err, "Should fail with 404 error") + assert.Contains(t, err.Error(), "404") + assert.Equal(t, 1, attemptCount, "Should only attempt once (no retries for 4xx)") + }) + + t.Run("retries multiple flags independently", func(t *testing.T) { + defer gock.Off() + + // Mock POST for first flag - fails once, then succeeds + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + var body map[string]interface{} + _ = json.NewDecoder(req.Body).Decode(&body) + return body["key"] == "flag1", nil + }). + Reply(500). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Internal Server Error", + "status": 500, + }, + }) + + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + var body map[string]interface{} + _ = json.NewDecoder(req.Body).Decode(&body) + return body["key"] == "flag1", nil + }). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": "flag1", + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + + // Mock POST for second flag - succeeds on first try + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + AddMatcher(func(req *http.Request, _ *gock.Request) (bool, error) { + var body map[string]interface{} + _ = json.NewDecoder(req.Body).Decode(&body) + return body["key"] == "flag2", nil + }). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": "flag2", + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "flag1", Type: flagset.BoolType, DefaultValue: true}, + {Key: "flag2", Type: flagset.StringType, DefaultValue: "test"}, + }, + } + remoteFlags := &flagset.Flagset{Flags: []flagset.Flag{}} + + _, err = client.PushFlags(ctx, localFlags, remoteFlags, false) + assert.NoError(t, err, "Should succeed with both flags") + assert.True(t, gock.IsDone(), "All expected requests should be made") + }) + + t.Run("dry run mode does not make API calls", func(t *testing.T) { + // No gock mocks needed - dry run should not make any HTTP requests + + client, err := NewClient("https://api.example.com", "") + require.NoError(t, err) + + ctx := context.Background() + localFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "new-flag", Type: flagset.BoolType, DefaultValue: true, Description: "New flag"}, + {Key: "existing-flag", Type: flagset.StringType, DefaultValue: "updated", Description: "Updated flag"}, + {Key: "unchanged-flag", Type: flagset.IntType, DefaultValue: 42, Description: "Unchanged"}, + }, + } + + remoteFlags := &flagset.Flagset{ + Flags: []flagset.Flag{ + {Key: "existing-flag", Type: flagset.StringType, DefaultValue: "old", Description: "Old flag"}, + {Key: "unchanged-flag", Type: flagset.IntType, DefaultValue: 42, Description: "Unchanged"}, + }, + } + + // Run in dry run mode + result, err := client.PushFlags(ctx, localFlags, remoteFlags, true) + assert.NoError(t, err, "Dry run should not error") + require.NotNil(t, result) + + // Verify the result shows what would be created and updated + assert.Len(t, result.Created, 1, "Should identify 1 flag to create") + assert.Equal(t, "new-flag", result.Created[0].Key) + + assert.Len(t, result.Updated, 1, "Should identify 1 flag to update") + assert.Equal(t, "existing-flag", result.Updated[0].Key) + }) +} + +func TestIsTransientHTTPError(t *testing.T) { + tests := []struct { + name string + err error + shouldRetry bool + }{ + { + name: "nil error is not transient", + err: nil, + shouldRetry: false, + }, + { + name: "500 error is transient", + err: &httpError{ + statusCode: 500, + message: "Internal Server Error", + }, + shouldRetry: true, + }, + { + name: "502 error is transient", + err: &httpError{ + statusCode: 502, + message: "Bad Gateway", + }, + shouldRetry: true, + }, + { + name: "503 error is transient", + err: &httpError{ + statusCode: 503, + message: "Service Unavailable", + }, + shouldRetry: true, + }, + { + name: "504 error is transient", + err: &httpError{ + statusCode: 504, + message: "Gateway Timeout", + }, + shouldRetry: true, + }, + { + name: "400 error is not transient", + err: &httpError{ + statusCode: 400, + message: "Bad Request", + }, + shouldRetry: false, + }, + { + name: "401 error is not transient", + err: &httpError{ + statusCode: 401, + message: "Unauthorized", + }, + shouldRetry: false, + }, + { + name: "404 error is not transient", + err: &httpError{ + statusCode: 404, + message: "Not Found", + }, + shouldRetry: false, + }, + { + name: "409 error is not transient", + err: &httpError{ + statusCode: 409, + message: "Conflict", + }, + shouldRetry: false, + }, + { + name: "200 success is not transient", + err: &httpError{ + statusCode: 200, + message: "OK", + }, + shouldRetry: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isTransientHTTPError(tt.err) + assert.Equal(t, tt.shouldRetry, result) + }) + } +} diff --git a/internal/cmd/pull.go b/internal/cmd/pull.go index b6c277a..1652e21 100644 --- a/internal/cmd/pull.go +++ b/internal/cmd/pull.go @@ -66,11 +66,22 @@ Why pull from a remote source: } flags = loadedFlags case "http", "https": - loadedFlags, err := manifest.LoadFromRemote(flagSourceUrl, authToken) - if err != nil { - return fmt.Errorf("error fetching flags from remote source: %w", err) + urlContainsAFileExtension := manifest.URLLooksLikeAFile(parsedURL.String()) + if urlContainsAFileExtension { + // Use direct HTTP requests for pulling flags from file-like URLs + loadedFlags, err := manifest.LoadFromRemote(flagSourceUrl, authToken) + if err != nil { + return fmt.Errorf("error fetching flags from remote source: %w", err) + } + flags = loadedFlags + } else { + // Use the sync API client for pulling flags + loadedFlags, err := manifest.LoadFromSyncAPI(flagSourceUrl, authToken) + if err != nil { + return fmt.Errorf("error fetching flags from remote source: %w", err) + } + flags = loadedFlags } - flags = loadedFlags default: return fmt.Errorf("unsupported URL scheme: %s. Supported schemes are file://, http://, and https://", parsedURL.Scheme) } diff --git a/internal/cmd/pull_test.go b/internal/cmd/pull_test.go index 326e5da..0680415 100644 --- a/internal/cmd/pull_test.go +++ b/internal/cmd/pull_test.go @@ -42,25 +42,39 @@ func TestPull(t *testing.T) { fs := setupTest(t) defer gock.Off() - flags := []map[string]any{ - {"key": "testFlag", "type": "boolean", "defaultValue": true}, - {"key": "testFlag2", "type": "string", "defaultValue": "string value"}, + // Mock response in OpenAPI ManifestEnvelope format + manifestResponse := map[string]any{ + "flags": []map[string]any{ + { + "key": "testFlag", + "type": "boolean", + "defaultValue": true, + "description": "Test boolean flag", + }, + { + "key": "testFlag2", + "type": "string", + "defaultValue": "string value", + "description": "Test string flag", + }, + }, } - gock.New("https://example.com/flags"). - Get("/"). + // Mock the sync API endpoint + gock.New("https://example.com"). + Get("/openfeature/v0/manifest"). Reply(200). - JSON(flags) + JSON(manifestResponse) cmd := GetPullCmd() // global flag exists on root only. config.AddRootFlags(cmd) - // Prepare command arguments + // Prepare command arguments - use base URL only args := []string{ "pull", - "--flag-source-url", "https://example.com/flags", + "--flag-source-url", "https://example.com", "--manifest", "manifest/path.json", } @@ -79,6 +93,7 @@ func TestPull(t *testing.T) { assert.NoError(t, err) // Compare actual content with expected flags + flags := manifestResponse["flags"].([]map[string]any) for _, flag := range flags { flagKey := flag["key"].(string) _, exists := manifestFlags["flags"].(map[string]interface{})[flagKey] @@ -90,19 +105,26 @@ func TestPull(t *testing.T) { setupTest(t) defer gock.Off() - gock.New("https://example.com/flags"). - Get("/"). - Reply(404) + // Mock error response from sync API + gock.New("https://example.com"). + Get("/openfeature/v0/manifest"). + Reply(404). + JSON(map[string]any{ + "error": map[string]any{ + "message": "Not found", + "status": 404, + }, + }) cmd := GetPullCmd() // global flag exists on root only. config.AddRootFlags(cmd) - // Prepare command arguments + // Prepare command arguments - use base URL only args := []string{ "pull", - "--flag-source-url", "https://example.com/flags", + "--flag-source-url", "https://example.com", "--manifest", "manifest/path.json", } @@ -111,6 +133,219 @@ func TestPull(t *testing.T) { // Run command err := cmd.Execute() assert.Error(t, err) - assert.Contains(t, err.Error(), "Received error response from flag source") + assert.Contains(t, err.Error(), "unexpected status code 404") + }) + + t.Run("pull with .json URL uses LoadFromRemote", func(t *testing.T) { + fs := setupTest(t) + defer gock.Off() + + // Mock response - direct file response, not wrapped in OpenAPI format + flagsResponse := map[string]any{ + "flags": map[string]any{ + "jsonFileFlag": map[string]any{ + "flagType": "boolean", + "defaultValue": true, + "description": "Flag from JSON file", + }, + }, + } + + // Mock direct HTTP GET to the file URL (no /openfeature/v0/manifest suffix) + gock.New("https://example.com"). + Get("/flags.json"). + Reply(200). + JSON(flagsResponse) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments - URL with .json extension + args := []string{ + "pull", + "--flag-source-url", "https://example.com/flags.json", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.NoError(t, err) + + // Verify the manifest was written + content, err := afero.ReadFile(fs, "manifest/path.json") + assert.NoError(t, err) + + var manifestFlags map[string]interface{} + err = json.Unmarshal(content, &manifestFlags) + assert.NoError(t, err) + + // Verify the flag exists in the manifest + flags := manifestFlags["flags"].(map[string]interface{}) + _, exists := flags["jsonFileFlag"] + assert.True(t, exists, "Flag jsonFileFlag should exist in manifest") + }) + + t.Run("pull with .yaml URL uses LoadFromRemote", func(t *testing.T) { + fs := setupTest(t) + defer gock.Off() + + // Mock response - direct file response + flagsResponse := map[string]any{ + "flags": map[string]any{ + "yamlFileFlag": map[string]any{ + "flagType": "string", + "defaultValue": "yaml value", + "description": "Flag from YAML file", + }, + }, + } + + // Mock direct HTTP GET to the file URL (no /openfeature/v0/manifest suffix) + gock.New("https://example.com"). + Get("/flags.yaml"). + Reply(200). + JSON(flagsResponse) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments - URL with .yaml extension + args := []string{ + "pull", + "--flag-source-url", "https://example.com/flags.yaml", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.NoError(t, err) + + // Verify the manifest was written + content, err := afero.ReadFile(fs, "manifest/path.json") + assert.NoError(t, err) + + var manifestFlags map[string]interface{} + err = json.Unmarshal(content, &manifestFlags) + assert.NoError(t, err) + + // Verify the flag exists in the manifest + flags := manifestFlags["flags"].(map[string]interface{}) + _, exists := flags["yamlFileFlag"] + assert.True(t, exists, "Flag yamlFileFlag should exist in manifest") + }) + + t.Run("pull with .yml URL uses LoadFromRemote", func(t *testing.T) { + fs := setupTest(t) + defer gock.Off() + + // Mock response - direct file response + flagsResponse := map[string]any{ + "flags": map[string]any{ + "ymlFileFlag": map[string]any{ + "flagType": "integer", + "defaultValue": 42, + "description": "Flag from YML file", + }, + }, + } + + // Mock direct HTTP GET to the file URL (no /openfeature/v0/manifest suffix) + gock.New("https://example.com"). + Get("/config.yml"). + Reply(200). + JSON(flagsResponse) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments - URL with .yml extension + args := []string{ + "pull", + "--flag-source-url", "https://example.com/config.yml", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.NoError(t, err) + + // Verify the manifest was written + content, err := afero.ReadFile(fs, "manifest/path.json") + assert.NoError(t, err) + + var manifestFlags map[string]interface{} + err = json.Unmarshal(content, &manifestFlags) + assert.NoError(t, err) + + // Verify the flag exists in the manifest + flags := manifestFlags["flags"].(map[string]interface{}) + _, exists := flags["ymlFileFlag"] + assert.True(t, exists, "Flag ymlFileFlag should exist in manifest") + }) + + t.Run("pull with non-file URL uses LoadFromSyncAPI", func(t *testing.T) { + fs := setupTest(t) + defer gock.Off() + + // Mock response in OpenAPI ManifestEnvelope format + manifestResponse := map[string]any{ + "flags": []map[string]any{ + { + "key": "syncApiFlag", + "type": "boolean", + "defaultValue": false, + "description": "Flag from sync API", + }, + }, + } + + // Mock the sync API endpoint - note the /openfeature/v0/manifest suffix + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(manifestResponse) + + cmd := GetPullCmd() + + // global flag exists on root only. + config.AddRootFlags(cmd) + + // Prepare command arguments - base URL without file extension + args := []string{ + "pull", + "--flag-source-url", "https://api.example.com", + "--manifest", "manifest/path.json", + } + + cmd.SetArgs(args) + + // Run command + err := cmd.Execute() + assert.NoError(t, err) + + // Verify the manifest was written + content, err := afero.ReadFile(fs, "manifest/path.json") + assert.NoError(t, err) + + var manifestFlags map[string]interface{} + err = json.Unmarshal(content, &manifestFlags) + assert.NoError(t, err) + + // Verify the flag exists in the manifest + flags := manifestFlags["flags"].(map[string]interface{}) + _, exists := flags["syncApiFlag"] + assert.True(t, exists, "Flag syncApiFlag should exist in manifest") }) } diff --git a/internal/cmd/push.go b/internal/cmd/push.go new file mode 100644 index 0000000..72c7b7c --- /dev/null +++ b/internal/cmd/push.go @@ -0,0 +1,200 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/url" + + "github.com/open-feature/cli/internal/api/sync" + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/manifest" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +// GetPushCmd returns the command for pushing flags to a remote source +func GetPushCmd() *cobra.Command { + pushCmd := &cobra.Command{ + Use: "push", + Short: "Push flag configurations to a remote source", + Long: `The push command syncs local flag configurations to a remote flag management service. + +This command reads your local flag manifest and intelligently pushes it to a specified +remote destination. It performs a smart push by: + +1. Fetching existing flags from the remote +2. Comparing local flags with remote flags +3. Creating new flags that don't exist remotely +4. Updating existing flags that have changed + +This approach ensures idempotent operations and prevents conflicts. + +The pushed data follows the Manifest Management API OpenAPI specification defined at: +api/v0/sync.yaml + +The API uses individual flag endpoints: +- POST /openfeature/v0/manifest/flags - Creates new flags +- PUT /openfeature/v0/manifest/flags/{key} - Updates existing flags +- GET /openfeature/v0/manifest - Fetches existing flags for comparison + +Remote services implementing this API should accept the flag data in the format +specified by the OpenFeature flag manifest schema. + +Note: The file:// scheme is not supported for push operations. +For local file operations, use standard shell commands like cp or mv.`, + Example: ` # Push flags to a remote HTTPS endpoint (smart push: creates and updates as needed) + openfeature push --flag-source-url https://api.example.com --auth-token secret-token + + # Push flags to an HTTP endpoint (development) + openfeature push --flag-source-url http://localhost:8080 + + # Dry run to preview what would be sent + openfeature push --flag-source-url https://api.example.com --dry-run`, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "push") + }, + RunE: func(cmd *cobra.Command, args []string) error { + // Get configuration values + flagSourceUrl := config.GetFlagSourceUrl(cmd) + manifestPath := config.GetManifestPath(cmd) + authToken := config.GetAuthToken(cmd) + dryRun := config.GetDryRun(cmd) + + // Validate destination URL is provided + if flagSourceUrl == "" { + return fmt.Errorf("flag source URL is required. Please provide --flag-source-url") + } + + // Parse and validate URL + parsedURL, err := url.Parse(flagSourceUrl) + if err != nil { + return fmt.Errorf("invalid source URL: %w", err) + } + + // Load the local manifest + flags, err := manifest.LoadFlagSet(manifestPath) + if err != nil { + return fmt.Errorf("error loading manifest from %s: %w", manifestPath, err) + } + + // Validation of required fields is handled by manifest.LoadFlagSet + + // Handle URL schemes + switch parsedURL.Scheme { + case "file": + return fmt.Errorf("file:// scheme is not supported for push. Use standard shell commands (cp, mv) for local file operations") + case "http", "https": + // Perform smart push (fetches remote, compares, and creates/updates as needed) + // In dry run mode, performs comparison but skips actual API calls + result, err := manifest.SaveToRemote(flagSourceUrl, flags, authToken, dryRun) + if err != nil { + return fmt.Errorf("error pushing flags to remote destination: %w", err) + } + + // Display the results + displayPushResults(result, flagSourceUrl, dryRun) + default: + return fmt.Errorf("unsupported URL scheme: %s. Supported schemes are http:// and https://", parsedURL.Scheme) + } + + return nil + }, + } + + // Add push-specific flags + config.AddPushFlags(pushCmd) + + // Add common flags (like --manifest) + config.AddRootFlags(pushCmd) + + return pushCmd +} + +// displayPushResults renders the push operation results with color-coded output +// If dryRun is true, displays what would be pushed instead of what was pushed +func displayPushResults(result *sync.PushResult, destination string, dryRun bool) { + totalChanges := len(result.Created) + len(result.Updated) + + // Extract just the base URL (domain) for cleaner display + displayURL := destination + if parsedURL, err := url.Parse(destination); err == nil { + // Build base URL with just scheme and host + displayURL = fmt.Sprintf("%s://%s", parsedURL.Scheme, parsedURL.Host) + } + + // Determine message based on dry run mode + if totalChanges == 0 { + if dryRun { + pterm.Info.Println("DRY RUN: No changes needed - all flags are already up to date.") + } else { + pterm.Success.Println("No changes needed - all flags are already up to date.") + } + return + } + + if dryRun { + pterm.Info.Printf("DRY RUN: Would push %d flag(s) to %s\n\n", totalChanges, displayURL) + } else { + pterm.Success.Printf("Successfully pushed %d flag(s) to %s\n\n", totalChanges, displayURL) + } + + // Display created flags + if len(result.Created) > 0 { + if dryRun { + pterm.FgCyan.Printf("◆ Would Create (%d):\n", len(result.Created)) + } else { + pterm.FgGreen.Printf("◆ Created (%d):\n", len(result.Created)) + } + + for _, flag := range result.Created { + if dryRun { + pterm.FgCyan.Printf(" + %s", flag.Key) + } else { + pterm.FgGreen.Printf(" + %s", flag.Key) + } + + if flag.Description != "" { + fmt.Printf(" - %s", flag.Description) + } + fmt.Println() + + // Show flag details + flagJSON, _ := json.MarshalIndent(map[string]interface{}{ + "type": flag.Type.String(), + "defaultValue": flag.DefaultValue, + }, " ", " ") + fmt.Printf(" %s\n", flagJSON) + } + fmt.Println() + } + + // Display updated flags + if len(result.Updated) > 0 { + if dryRun { + pterm.FgMagenta.Printf("◆ Would Update (%d):\n", len(result.Updated)) + } else { + pterm.FgYellow.Printf("◆ Updated (%d):\n", len(result.Updated)) + } + + for _, flag := range result.Updated { + if dryRun { + pterm.FgMagenta.Printf(" ~ %s", flag.Key) + } else { + pterm.FgYellow.Printf(" ~ %s", flag.Key) + } + + if flag.Description != "" { + fmt.Printf(" - %s", flag.Description) + } + fmt.Println() + + // Show flag details + flagJSON, _ := json.MarshalIndent(map[string]interface{}{ + "type": flag.Type.String(), + "defaultValue": flag.DefaultValue, + }, " ", " ") + fmt.Printf(" %s\n", flagJSON) + } + fmt.Println() + } +} diff --git a/internal/cmd/push_test.go b/internal/cmd/push_test.go new file mode 100644 index 0000000..9013db7 --- /dev/null +++ b/internal/cmd/push_test.go @@ -0,0 +1,539 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "testing" + + "github.com/h2non/gock" + "github.com/open-feature/cli/internal/filesystem" + + "github.com/spf13/afero" + + "github.com/stretchr/testify/assert" +) + +func setupPushTest(t *testing.T) afero.Fs { + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + // Copy test manifest to the filesystem + readOsFileAndWriteToMemMap(t, "testdata/success_manifest.golden", "flags.json", fs) + return fs +} + +func TestPush(t *testing.T) { + t.Run("push without destination URL", func(t *testing.T) { + setupPushTest(t) + cmd := GetPushCmd() + + args := []string{ + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "flag source URL is required") + }) + + t.Run("smart push creates new flags", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request to fetch remote flags (returns empty list) + emptyFlags := []map[string]interface{}{} + gock.New("http://localhost:8080"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": emptyFlags, + }) + + // Mock individual POST requests for each flag in the manifest + // Since remote has no flags, all local flags will be created + flagKeys := []string{"enableFeatureA", "usernameMaxLength", "greetingMessage", "discountPercentage", "themeCustomization"} + for _, flagKey := range flagKeys { + gock.New("http://localhost:8080"). + Post("/openfeature/v0/manifest/flags"). + MatchType("application/json"). + MatchHeader("Content-Type", "application/json"). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": flagKey, + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + } + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "http://localhost:8080/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + // Verify that all mocked requests were made + assert.True(t, gock.IsDone(), "Not all expected HTTP requests were made") + }) + + t.Run("smart push updates existing flags", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request to fetch remote flags (all flags already exist) + flagKeys := []string{"enableFeatureA", "usernameMaxLength", "greetingMessage", "discountPercentage", "themeCustomization"} + remoteFlags := make([]map[string]interface{}, 0) + for _, flagKey := range flagKeys { + remoteFlags = append(remoteFlags, map[string]interface{}{ + "key": flagKey, + "type": "boolean", + "defaultValue": false, + }) + } + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": remoteFlags, + }) + + // Mock individual PUT requests for each flag + for _, flagKey := range flagKeys { + gock.New("https://api.example.com"). + Put("/openfeature/v0/manifest/flags/"+flagKey). + MatchType("application/json"). + MatchHeader("Content-Type", "application/json"). + Reply(200). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": flagKey, + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + } + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + assert.True(t, gock.IsDone(), "Not all expected HTTP requests were made") + }) + + t.Run("smart push with mixed create and update", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request - some flags exist, some don't + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": []map[string]interface{}{ + { + "key": "enableFeatureA", + "type": "boolean", + "defaultValue": false, + }, + { + "key": "usernameMaxLength", + "type": "integer", + "defaultValue": 10, + }, + }, + }) + + // Mock PUT requests for existing flags + existingFlags := []string{"enableFeatureA", "usernameMaxLength"} + for _, flagKey := range existingFlags { + gock.New("https://api.example.com"). + Put("/openfeature/v0/manifest/flags/" + flagKey). + MatchType("application/json"). + Reply(200). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": flagKey, + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + } + + // Mock POST requests for new flags + newFlags := []string{"greetingMessage", "discountPercentage", "themeCustomization"} + for _, flagKey := range newFlags { + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + MatchType("application/json"). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": flagKey, + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + } + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + assert.True(t, gock.IsDone(), "Not all expected HTTP requests were made") + }) + + t.Run("push with authentication token", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request with auth header + emptyFlags := []map[string]interface{}{} + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + MatchHeader("Authorization", "Bearer secret-token"). + Reply(200). + JSON(map[string]interface{}{ + "flags": emptyFlags, + }) + + // Mock individual POST requests with auth header for each flag + flagKeys := []string{"enableFeatureA", "usernameMaxLength", "greetingMessage", "discountPercentage", "themeCustomization"} + for _, flagKey := range flagKeys { + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + MatchType("application/json"). + MatchHeader("Authorization", "Bearer secret-token"). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": flagKey, + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + } + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--auth-token", "secret-token", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + assert.True(t, gock.IsDone(), "Not all expected HTTP requests were made") + }) + + t.Run("push with dry run", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request to fetch remote flags (returns some existing flags) + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]any{ + "flags": []map[string]any{ + { + "key": "enableFeatureA", + "type": "boolean", + "defaultValue": false, // Different from local + "description": "Old description", + }, + { + "key": "usernameMaxLength", + "type": "integer", + "defaultValue": 10, + "description": "Max username length", + }, + }, + }) + + // Dry run should NOT make any POST or PUT requests + // If any POST/PUT requests are made, gock will fail to match and the test will fail + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com", + "--dry-run", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + + // Verify that only the GET request was made (no POST/PUT) + // gock.IsDone() returns true if all mocked requests were consumed + assert.True(t, gock.IsDone(), "Should only make GET request, not POST/PUT") + }) + + t.Run("push with file scheme returns error", func(t *testing.T) { + setupPushTest(t) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "file:///local/path/flags.json", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "file:// scheme is not supported for push") + assert.Contains(t, err.Error(), "Use standard shell commands") + }) + + t.Run("push with unsupported scheme returns error", func(t *testing.T) { + setupPushTest(t) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "ftp://example.com/flags", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "unsupported URL scheme: ftp") + assert.Contains(t, err.Error(), "Supported schemes are http:// and https://") + }) + + t.Run("error when fetch returns 404", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request returning 404 + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(404). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Not Found", + "status": 404, + }, + }) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to fetch remote flags") + }) + + t.Run("error when create endpoint returns 404", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request (empty flags) + emptyFlags := []map[string]interface{}{} + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": emptyFlags, + }) + + // Mock a 404 error for all flag creation requests + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Persist(). // Apply to all requests + Reply(404). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Not Found", + "status": 404, + }, + }) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create flag") + assert.Contains(t, err.Error(), "404") + }) + + t.Run("error when endpoint returns 500", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request (empty flags) + emptyFlags := []map[string]interface{}{} + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": emptyFlags, + }) + + // Mock a 500 error for all flag creation requests + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + Persist(). // Apply to all requests + Reply(500). + JSON(map[string]interface{}{ + "error": map[string]interface{}{ + "message": "Internal Server Error", + "status": 500, + }, + }) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to create flag") + assert.Contains(t, err.Error(), "500") + }) + + t.Run("push validates request body format", func(t *testing.T) { + setupPushTest(t) + defer gock.Off() + + // Mock GET request (empty flags) + emptyFlags := []map[string]interface{}{} + gock.New("https://api.example.com"). + Get("/openfeature/v0/manifest"). + Reply(200). + JSON(map[string]interface{}{ + "flags": emptyFlags, + }) + + // Mock all POST requests and validate that the right fields are present + // We can't predict the order flags will be processed, so we validate structure not content + requestCount := 0 + gock.New("https://api.example.com"). + Post("/openfeature/v0/manifest/flags"). + MatchType("application/json"). + SetMatcher(gock.NewMatcher()). + AddMatcher(func(req *http.Request, ereq *gock.Request) (bool, error) { + // Verify the request has the required fields: key, type, defaultValue + var body map[string]interface{} + decoder := json.NewDecoder(req.Body) + if err := decoder.Decode(&body); err != nil { + return false, err + } + // Check required fields exist + if _, ok := body["key"]; !ok { + return false, fmt.Errorf("missing key field") + } + if _, ok := body["type"]; !ok { + return false, fmt.Errorf("missing type field") + } + if _, ok := body["defaultValue"]; !ok { + return false, fmt.Errorf("missing defaultValue field") + } + requestCount++ + return true, nil + }). + Persist(). + Reply(201). + JSON(map[string]interface{}{ + "flag": map[string]interface{}{ + "key": "test", + }, + "updatedAt": "2024-03-02T09:45:03.000Z", + }) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/openfeature/v0/manifest", + "--manifest", "flags.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, 5, requestCount, "Expected 5 flag creation requests") + }) + + t.Run("error when manifest file does not exist", func(t *testing.T) { + setupPushTest(t) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/flags", + "--manifest", "nonexistent.json", + } + cmd.SetArgs(args) + + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "error loading manifest") + }) + + t.Run("push with manifest containing flag without default value", func(t *testing.T) { + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create a manifest with a flag missing a default value + invalidManifest := `{ + "flags": { + "testFlag": { + "flagType": "boolean", + "description": "Test flag without default" + } + } + }` + err := afero.WriteFile(fs, "invalid.json", []byte(invalidManifest), 0644) + assert.NoError(t, err) + + cmd := GetPushCmd() + + args := []string{ + "--flag-source-url", "https://api.example.com/flags", + "--manifest", "invalid.json", + } + cmd.SetArgs(args) + + err = cmd.Execute() + assert.Error(t, err) + // The error message is from manifest validation + assert.Contains(t, err.Error(), "defaultValue is required") + }) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 93daa5f..f4091e2 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -64,6 +64,7 @@ func GetRootCmd() *cobra.Command { rootCmd.AddCommand(GetGenerateCmd()) rootCmd.AddCommand(GetCompareCmd()) rootCmd.AddCommand(GetPullCmd()) + rootCmd.AddCommand(GetPushCmd()) rootCmd.AddCommand(GetManifestCmd()) // Add a custom error handler after the command is created diff --git a/internal/config/flags.go b/internal/config/flags.go index 203d6be..35540d3 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -21,6 +21,7 @@ const ( FlagSourceUrlFlagName = "flag-source-url" AuthTokenFlagName = "auth-token" NoPromptFlagName = "no-prompt" + DryRunFlagName = "dry-run" TypeFlagName = "type" DefaultValueFlagName = "default-value" DescriptionFlagName = "description" @@ -75,6 +76,13 @@ func AddPullFlags(cmd *cobra.Command) { cmd.Flags().Bool(NoPromptFlagName, false, "Disable interactive prompts for missing default values") } +// AddPushFlags adds the push command specific flags +func AddPushFlags(cmd *cobra.Command) { + cmd.Flags().String(FlagSourceUrlFlagName, "", "The URL of the flag destination") + cmd.Flags().String(AuthTokenFlagName, "", "The auth token for the flag destination") + cmd.Flags().Bool(DryRunFlagName, false, "Preview changes without pushing") +} + // GetManifestPath gets the manifest path from the given command func GetManifestPath(cmd *cobra.Command) string { manifestPath, _ := cmd.Flags().GetString(ManifestFlagName) @@ -117,21 +125,20 @@ func GetOverride(cmd *cobra.Command) bool { return override } +// getConfigValueWithFallback is a helper function that attempts to get a value from Viper config +// if the provided value is empty. This reduces duplication for flag source/destination URLs. +func getConfigValueWithFallback(value string, configKey string) string { + if value != "" { + return value + } + // Viper is already configured in initializeConfig, just retrieve the value + return viper.GetString(configKey) +} + // GetFlagSourceUrl gets the flag source URL from the given command func GetFlagSourceUrl(cmd *cobra.Command) string { flagSourceUrl, _ := cmd.Flags().GetString(FlagSourceUrlFlagName) - if flagSourceUrl == "" { - viper.SetConfigName(".openfeature") - viper.AddConfigPath(".") - if err := viper.ReadInConfig(); err != nil { - return "" - } - if !viper.IsSet("flagSourceUrl") { - return "" - } - flagSourceUrl = viper.GetString("flagSourceUrl") - } - return flagSourceUrl + return getConfigValueWithFallback(flagSourceUrl, "flagSourceUrl") } // GetAuthToken gets the auth token from the given command @@ -146,6 +153,12 @@ func GetNoPrompt(cmd *cobra.Command) bool { return noPrompt } +// GetDryRun gets the dry-run flag from the given command +func GetDryRun(cmd *cobra.Command) bool { + dryRun, _ := cmd.Flags().GetBool(DryRunFlagName) + return dryRun +} + // AddManifestAddFlags adds the manifest add command specific flags func AddManifestAddFlags(cmd *cobra.Command) { cmd.Flags().StringP(TypeFlagName, "t", "boolean", "Type of the flag (boolean, string, integer, float, object)") diff --git a/internal/flagset/flagset.go b/internal/flagset/flagset.go index 04a72cc..60e6629 100644 --- a/internal/flagset/flagset.go +++ b/internal/flagset/flagset.go @@ -58,8 +58,8 @@ func (fs *Flagset) Filter(unsupportedFlagTypes map[FlagType]bool) *Flagset { return &filtered } -// parseFlagType converts a string flag type to FlagType enum -func parseFlagType(typeStr string) (FlagType, error) { +// ParseFlagType converts a string flag type to FlagType enum +func ParseFlagType(typeStr string) (FlagType, error) { switch typeStr { case "integer", "Integer": return IntType, nil @@ -91,7 +91,7 @@ func (fs *Flagset) UnmarshalJSON(data []byte) error { } for key, flag := range manifest.Flags { - flagType, err := parseFlagType(flag.FlagType) + flagType, err := ParseFlagType(flag.FlagType) if err != nil { return err } @@ -158,8 +158,8 @@ func LoadFromSourceFlags(data []byte) (*[]Flag, error) { var sourceFlagsArray []SourceFlag - if err := json.Unmarshal(data, &sourceWithWrapper); err == nil && len(sourceWithWrapper.Flags) > 0 { - // Successfully unmarshaled as object with flags property + if err := json.Unmarshal(data, &sourceWithWrapper); err == nil && sourceWithWrapper.Flags != nil { + // Successfully unmarshaled as object with flags property (even if empty) sourceFlagsArray = sourceWithWrapper.Flags } else { // Try to unmarshal as a direct array of flags (for backward compatibility) @@ -170,7 +170,7 @@ func LoadFromSourceFlags(data []byte) (*[]Flag, error) { var flags []Flag for _, sf := range sourceFlagsArray { - flagType, err := parseFlagType(sf.Type) + flagType, err := ParseFlagType(sf.Type) if err != nil { return nil, err } diff --git a/internal/manifest/manage.go b/internal/manifest/manage.go index 5c8d86a..e8933c6 100644 --- a/internal/manifest/manage.go +++ b/internal/manifest/manage.go @@ -1,15 +1,19 @@ package manifest import ( + "context" "encoding/json" "errors" "fmt" "io" "net/http" "path/filepath" + "strings" + "github.com/open-feature/cli/internal/api/sync" "github.com/open-feature/cli/internal/filesystem" "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/logger" "github.com/spf13/afero" ) @@ -81,7 +85,22 @@ func LoadFromLocal(filePath string) (*flagset.Flagset, error) { return flags, nil } -// LoadFromRemote loads flags from a remote URL +// LoadFromSyncAPI loads flags from a remote URL using the sync API client +// This should be used when the remote source implements the sync API specification +func LoadFromSyncAPI(baseURL string, authToken string) (*flagset.Flagset, error) { + logger.Default.Debug(fmt.Sprintf("Loading flags from sync API at %s", baseURL)) + + client, err := sync.NewClient(baseURL, authToken) + if err != nil { + return nil, fmt.Errorf("failed to create sync client: %w", err) + } + + ctx := context.Background() + return client.PullFlags(ctx) +} + +// LoadFromRemote loads flags from a remote URL using direct HTTP requests +// This is a fallback for sources that don't implement the sync API specification func LoadFromRemote(url string, authToken string) (*flagset.Flagset, error) { req, err := http.NewRequest("GET", url, nil) if err != nil { @@ -103,13 +122,26 @@ func LoadFromRemote(url string, authToken string) (*flagset.Flagset, error) { return nil, err } + logger.Default.Debug(fmt.Sprintf("Fetched from %s (status %d):\n%s", url, resp.StatusCode, string(body))) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("Received error response from flag source: %s", string(body)) + return nil, fmt.Errorf("received error response from flag source: %s", string(body)) } return loadFlagsFromData(body) } +// URLLooksLikeAFile checks if the given URL string appears to point to a file +func URLLooksLikeAFile(url string) bool { + fileExtensions := []string{".json", ".yaml", ".yml"} + for _, ext := range fileExtensions { + if strings.HasSuffix(url, ext) { + return true + } + } + return false +} + // createInitManifest creates an initManifest with the given flags func createInitManifest(flags map[string]any) *initManifest { return &initManifest{ @@ -174,3 +206,29 @@ func loadFlagsFromData(data []byte) (*flagset.Flagset, error) { return &flagset.Flagset{Flags: *loadedFlags}, nil } + +// SaveToRemote saves flags to a remote URL using HTTP/HTTPS +// This function performs a smart push: it fetches remote flags first, +// compares them with local flags, and intelligently creates or updates +// flags as needed. Returns a PushResult with details of what was changed. +// If dryRun is true, only performs the comparison without making actual API calls. +func SaveToRemote(url string, flags *flagset.Flagset, authToken string, dryRun bool) (*sync.PushResult, error) { + // Use the generated OpenAPI client for type-safe API calls + client, err := sync.NewClient(url, authToken) + if err != nil { + return nil, fmt.Errorf("failed to create push client: %w", err) + } + + ctx := context.Background() + + // Fetch remote flags to compare with local flags using the sync client + logger.Default.Debug("Fetching remote flags for comparison") + remoteFlags, err := client.PullFlags(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch remote flags: %w", err) + } + logger.Default.Debug(fmt.Sprintf("Fetched %d remote flags", len(remoteFlags.Flags))) + + // Smart push: compare and intelligently create or update flags + return client.PushFlags(ctx, flags, remoteFlags, dryRun) +} diff --git a/internal/manifest/manage_test.go b/internal/manifest/manage_test.go new file mode 100644 index 0000000..c3b1e79 --- /dev/null +++ b/internal/manifest/manage_test.go @@ -0,0 +1,83 @@ +package manifest + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestURLLooksLikeAFile(t *testing.T) { + tests := []struct { + name string + url string + expected bool + }{ + { + name: "URL with .json extension", + url: "https://example.com/flags.json", + expected: true, + }, + { + name: "URL with .yaml extension", + url: "https://example.com/flags.yaml", + expected: true, + }, + { + name: "URL with .yml extension", + url: "https://example.com/flags.yml", + expected: true, + }, + { + name: "URL with path and .json extension", + url: "https://example.com/api/v0/flags.json", + expected: true, + }, + { + name: "URL with query params and .json extension", + url: "https://example.com/flags.json?version=1", + expected: false, // Query params come after extension + }, + { + name: "URL without file extension", + url: "https://example.com", + expected: false, + }, + { + name: "URL with path but no extension", + url: "https://example.com/api/v0/flags", + expected: false, + }, + { + name: "URL with different extension", + url: "https://example.com/flags.txt", + expected: false, + }, + { + name: "URL with .json in path but not at end", + url: "https://example.com/flags.json/export", + expected: false, + }, + { + name: "URL with uppercase extension", + url: "https://example.com/flags.JSON", + expected: false, // Case sensitive check + }, + { + name: "Empty URL", + url: "", + expected: false, + }, + { + name: "Short URL with .json", + url: ".json", + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := URLLooksLikeAFile(tt.url) + assert.Equal(t, tt.expected, result, "URLLooksLikeAFile(%q) should return %v", tt.url, tt.expected) + }) + } +} diff --git a/schema/v0/README.md b/schema/v0/README.md new file mode 100644 index 0000000..7b989a1 --- /dev/null +++ b/schema/v0/README.md @@ -0,0 +1,49 @@ +# OpenFeature CLI Schema Definitions + +This directory contains schema definitions for the OpenFeature CLI. + +## Files + +### flag-manifest.json +JSON Schema for the flag manifest file format. This schema defines the structure for feature flag configurations stored locally. + +## Sync API Implementation + +Services that want to sync flag configurations with the OpenFeature CLI should implement the API defined in `api/v0/sync.yaml`. + +Key requirements: +- Accept POST or PUT requests with JSON payload +- Support Bearer token authentication (optional) +- Validate flag data according to the manifest schema +- Return appropriate HTTP status codes + +### Example Request +```json +{ + "key": "feature-x-enabled", + "type": "boolean", + "description": "Enable feature X", + "defaultValue": true +} +``` + +### Example Success Response +```json +{ + "flag": { + "key": "feature-x-enabled", + "name": "feature-x-enabled", + "type": "boolean", + "description": "Enable feature X", + "defaultValue": true + }, + "updatedAt": "2025-11-03T20:41:52.000Z" +} +``` + +## Usage + +To view the OpenAPI specification in a user-friendly format, you can use tools like: +- [Swagger Editor](https://editor.swagger.io/) +- [Redoc](https://github.com/Redocly/redoc) +- [OpenAPI Generator](https://openapi-generator.tech/) to generate server stubs or client SDKs \ No newline at end of file