diff --git a/README.md b/README.md index d35f8d5..bcc5904 100644 --- a/README.md +++ b/README.md @@ -36,8 +36,8 @@ fm pages request get fm workspace auth login [--token-stdin] fm workspace auth status fm workspace auth logout -fm workspace openapi -fm workspace request get +fm workspace openapi [--raw] +fm workspace request get [--raw] ``` ## Installation @@ -138,12 +138,15 @@ have an approved SSO account. A `401 Unauthorized` response can mean that the token is invalid, expired, or revoked, or that the account is not approved. Workspace requests are GET-only and must stay under `/api/v1/`. Use `fm workspace openapi` (or `fm workspace schema`) to fetch the machine-readable -OpenAPI specification for discovery and self-description. +OpenAPI specification for discovery and self-description. Both `openapi` and +`request get` accept `--raw` to output the verbatim response body without JSON +envelopes, suitable for redirection or piping. ```sh fm workspace auth login fm workspace request get /api/v1/me fm workspace openapi +fm workspace openapi --raw > openapi.json ``` `publish` requires `--owner` and `--html-file`. `update` accepts an HTML diff --git a/lib/feedmob/cli/commands/request.rb b/lib/feedmob/cli/commands/request.rb index 298df97..722e1db 100644 --- a/lib/feedmob/cli/commands/request.rb +++ b/lib/feedmob/cli/commands/request.rb @@ -108,17 +108,26 @@ def service_name = 'pages' class WorkspaceRequestGet < RequestGet desc 'Perform an authenticated GET request against the FeedMob Workspace API' + option :raw, type: :boolean, default: false, desc: 'Write the response body verbatim (incompatible with --json)' - def call(path:, **) - validate_workspace_path!(path) - super + def call(path:, raw: false, **) + validate_workspace_path!(path, raw) + return super(path:) unless raw + + credential = credential!(service) + response = runtime.client(service).request(method: :get, path:, token: credential.value, raw: true) + (@out || $stdout).write(response.data) end def service_name = 'workspace' private - def validate_workspace_path!(path) + def validate_workspace_path!(path, raw) + if raw && FeedMob::CLI.json? + raise Error.new(code: 'invalid_input', message: '--raw cannot be combined with --json.') + end + return if path.to_s.start_with?('/api/v1/') raise Error.new( diff --git a/lib/feedmob/cli/commands/workspace.rb b/lib/feedmob/cli/commands/workspace.rb index 2fde0a5..ef778fa 100644 --- a/lib/feedmob/cli/commands/workspace.rb +++ b/lib/feedmob/cli/commands/workspace.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true +require 'json' require_relative 'base' - module FeedMob module CLI module Commands @@ -13,9 +13,25 @@ def service_name = 'workspace' class WorkspaceOpenapi < WorkspaceBase desc 'Fetch the OpenAPI schema for FeedMob Workspace' + option :raw, type: :boolean, default: false, desc: 'Write the response body verbatim (incompatible with --json)' + + def call(raw: false, **) + if raw && FeedMob::CLI.json? + raise Error.new(code: 'invalid_input', message: '--raw cannot be combined with --json.') + end - def call(**) credential = credential!(service) + if raw + response = runtime.client(service).request( + method: :get, + path: '/api/v1/openapi', + token: credential.value, + raw: true + ) + (@out || $stdout).write(response.data) + return + end + response = runtime.client(service).request( method: :get, path: '/api/v1/openapi', @@ -28,7 +44,7 @@ def call(**) status: response.status, response: response.data }, - message: "GET /api/v1/openapi returned HTTP #{response.status}." + message: JSON.pretty_generate(response.data) ) end end diff --git a/test/cli_commands_test.rb b/test/cli_commands_test.rb index 2a2251b..66d9ac4 100644 --- a/test/cli_commands_test.rb +++ b/test/cli_commands_test.rb @@ -219,6 +219,46 @@ def test_workspace_request_get_rejects_paths_outside_the_versioned_workspace_api assert_empty workspace_client.requests end + def test_workspace_request_get_raw_writes_verbatim_response + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + workspace_client = FakeClient.new( + [FeedMob::CLI::HTTP::Response.new(status: 200, headers: {}, data: '{"user":{"id":42}}')] + ) + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, stderr, status = run_cli('workspace', 'request', 'get', '/api/v1/me', '--raw') + + assert_equal 0, status + assert_empty stderr + assert_equal '{"user":{"id":42}}', stdout + assert_equal( + { method: :get, path: '/api/v1/me', token: 'fmapat_workspace', raw: true }, + workspace_client.requests.fetch(0) + ) + end + + def test_workspace_request_get_rejects_raw_combined_with_json + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + workspace_client = FakeClient.new + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, _stderr, status = run_cli('workspace', 'request', 'get', '/api/v1/me', '--raw', '--json') + + assert_equal 1, status + assert_equal 'invalid_input', JSON.parse(stdout).dig('error', 'code') + assert_empty workspace_client.requests + end + def test_workspace_openapi_fetches_the_versioned_openapi_schema credentials = FakeCredentials.new( credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') @@ -275,6 +315,66 @@ def test_workspace_openapi_requires_workspace_credential assert_empty workspace_client.requests end + def test_workspace_openapi_raw_writes_verbatim_schema + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + workspace_client = FakeClient.new( + [FeedMob::CLI::HTTP::Response.new(status: 200, headers: {}, data: '{"openapi":"3.0.0"}')] + ) + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, stderr, status = run_cli('workspace', 'openapi', '--raw') + + assert_equal 0, status + assert_empty stderr + assert_equal '{"openapi":"3.0.0"}', stdout + assert_equal( + { method: :get, path: '/api/v1/openapi', token: 'fmapat_workspace', raw: true }, + workspace_client.requests.fetch(0) + ) + end + + def test_workspace_openapi_rejects_raw_combined_with_json + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + workspace_client = FakeClient.new + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, _stderr, status = run_cli('workspace', 'openapi', '--raw', '--json') + + assert_equal 1, status + assert_equal 'invalid_input', JSON.parse(stdout).dig('error', 'code') + assert_empty workspace_client.requests + end + + def test_workspace_openapi_pretty_prints_schema_in_human_mode + credentials = FakeCredentials.new( + credential: FeedMob::CLI::Credential.new(value: 'fmapat_workspace', source: 'keychain') + ) + schema = { 'openapi' => '3.0.0', 'info' => { 'title' => 'FeedMob Admin API' } } + workspace_client = FakeClient.new( + [FeedMob::CLI::HTTP::Response.new(status: 200, headers: {}, data: schema)] + ) + use_runtime( + credentials:, + clients: { 'pixel' => FakeClient.new, 'time-off' => FakeClient.new, 'workspace' => workspace_client } + ) + + stdout, stderr, status = run_cli('workspace', 'openapi') + + assert_equal 0, status + assert_empty stderr + assert_equal "#{JSON.pretty_generate(schema)}\n", stdout + end + def test_pixel_logout_revokes_the_remote_token_then_deletes_local_keychain_value credentials = FakeCredentials.new pixel_client = FakeClient.new(