opn_api 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'yaml'
5
+
6
+ module OpnApi
7
+ module CLI
8
+ # Output formatter for CLI results. Supports table, JSON, and YAML output.
9
+ #
10
+ # Table output supports field filtering:
11
+ # - Explicit fields via -F (e.g. -F uuid,name,type)
12
+ # - Default: first 5 fields (to keep output readable)
13
+ # - All fields via -A
14
+ # JSON and YAML always output all data unmodified.
15
+ module Formatter
16
+ # Default number of columns shown in table output when no -F or -A is given.
17
+ DEFAULT_MAX_FIELDS = 5
18
+
19
+ module_function
20
+
21
+ # Formats data according to the specified format.
22
+ #
23
+ # @param data [Object] Data to format (Hash, Array, String)
24
+ # @param format [Symbol] Output format (:table, :json, :yaml)
25
+ # @param fields [Array<String>, nil] Explicit field names for table output
26
+ # @param all_fields [Boolean] Show all fields in table output
27
+ # @param show_empty [Boolean] Show empty fields in table output (default: hidden)
28
+ # @return [String] Formatted output
29
+ def format(data, format: :table, fields: nil, all_fields: false, show_empty: false)
30
+ case format
31
+ when :json
32
+ format_json(data)
33
+ when :yaml
34
+ format_yaml(data)
35
+ else
36
+ format_table(data, fields: fields, all_fields: all_fields, show_empty: show_empty)
37
+ end
38
+ end
39
+
40
+ # Pretty-printed JSON output (always full data).
41
+ def format_json(data)
42
+ JSON.pretty_generate(data)
43
+ end
44
+
45
+ # YAML output (always full data).
46
+ def format_yaml(data)
47
+ data.to_yaml
48
+ end
49
+
50
+ # Human-readable table output with optional field filtering.
51
+ def format_table(data, fields: nil, all_fields: false, show_empty: false)
52
+ case data
53
+ when Array
54
+ format_array_table(data, fields: fields, all_fields: all_fields)
55
+ when Hash
56
+ format_hash_table(data, show_empty: show_empty)
57
+ else
58
+ data.to_s
59
+ end
60
+ end
61
+
62
+ # Formats an array of hashes as a column-aligned table.
63
+ # Applies field filtering for readability.
64
+ def format_array_table(rows, fields: nil, all_fields: false)
65
+ return '(no results)' if rows.empty?
66
+
67
+ # Flatten nested values to strings for display
68
+ flat_rows = rows.map do |row|
69
+ row.transform_values { |v| v.is_a?(Hash) || v.is_a?(Array) ? JSON.generate(v) : v.to_s }
70
+ end
71
+
72
+ # Determine which columns to show
73
+ all_keys = flat_rows.flat_map(&:keys).uniq
74
+ display_keys = select_display_keys(all_keys, fields: fields, all_fields: all_fields)
75
+
76
+ # Calculate column widths
77
+ widths = display_keys.to_h do |k|
78
+ [k, ([k.length] + flat_rows.map { |r| r.fetch(k, '').length }).max]
79
+ end
80
+
81
+ # Header line
82
+ header = display_keys.map { |k| k.ljust(widths[k]) }.join(' ')
83
+ separator = display_keys.map { |k| '-' * widths[k] }.join(' ')
84
+
85
+ # Data lines
86
+ lines = flat_rows.map do |row|
87
+ display_keys.map { |k| row.fetch(k, '').ljust(widths[k]) }.join(' ')
88
+ end
89
+
90
+ # Add hint about hidden fields if any were truncated
91
+ result = [header, separator, *lines].join("\n")
92
+ hidden_count = all_keys.length - display_keys.length
93
+ if hidden_count.positive?
94
+ result += "\n\n(#{hidden_count} more field(s) hidden, use -A to show all or -F to select)"
95
+ end
96
+ result
97
+ end
98
+
99
+ # Formats a hash as key-value pairs with recursive indentation
100
+ # for nested structures.
101
+ #
102
+ # @param hash [Hash] Hash to format
103
+ # @param show_empty [Boolean] Include fields with empty string values
104
+ def format_hash_table(hash, show_empty: false)
105
+ return '(empty)' if hash.empty?
106
+
107
+ format_hash_recursive(hash, 0, show_empty: show_empty)
108
+ end
109
+
110
+ # Recursively formats a hash with indentation for nested structures.
111
+ #
112
+ # @param hash [Hash] Hash to format
113
+ # @param indent [Integer] Current indentation level
114
+ # @param show_empty [Boolean] Include fields with empty string values
115
+ # @return [String] Formatted output
116
+ def format_hash_recursive(hash, indent, show_empty: false)
117
+ prefix = ' ' * indent
118
+ # Filter out empty string values unless show_empty is set
119
+ display_hash = show_empty ? hash : hash.reject { |_k, v| v.is_a?(String) && v.empty? }
120
+ return "#{prefix}(all fields empty)" if display_hash.empty?
121
+
122
+ max_key = display_hash.keys.map { |k| k.to_s.length }.max
123
+
124
+ display_hash.map do |k, v|
125
+ label = "#{prefix}#{k.to_s.ljust(max_key)}"
126
+ format_value_recursive(label, v, indent, show_empty: show_empty)
127
+ end.join("\n")
128
+ end
129
+
130
+ # Formats a single value, recursing into hashes and arrays.
131
+ #
132
+ # @param label [String] Pre-formatted "key" label with padding
133
+ # @param value [Object] Value to format
134
+ # @param indent [Integer] Current indentation level
135
+ # @param show_empty [Boolean] Include fields with empty string values
136
+ # @return [String] Formatted line(s)
137
+ def format_value_recursive(label, value, indent, show_empty: false)
138
+ case value
139
+ when Hash
140
+ if value.empty?
141
+ "#{label} (empty)"
142
+ else
143
+ # Render nested hash on next lines with increased indent
144
+ "#{label}:\n#{format_hash_recursive(value, indent + 1, show_empty: show_empty)}"
145
+ end
146
+ when Array
147
+ if value.empty?
148
+ "#{label} (none)"
149
+ else
150
+ # Render array items on next lines with increased indent
151
+ items = value.map { |item| format_array_item(item, indent + 1, show_empty: show_empty) }
152
+ "#{label}:\n#{items.join("\n")}"
153
+ end
154
+ else
155
+ "#{label} #{value}"
156
+ end
157
+ end
158
+
159
+ # Formats a single array item with indentation.
160
+ #
161
+ # @param item [Object] Array element
162
+ # @param indent [Integer] Indentation level
163
+ # @param show_empty [Boolean] Include fields with empty string values
164
+ # @return [String] Formatted line(s)
165
+ def format_array_item(item, indent, show_empty: false)
166
+ prefix = ' ' * indent
167
+ case item
168
+ when Hash
169
+ # Render hash items with "- " prefix for first line
170
+ lines = format_hash_recursive(item, indent, show_empty: show_empty).split("\n")
171
+ first = lines.first&.sub(%r{\A#{Regexp.escape(prefix)}}, "#{prefix}- ")
172
+ rest = lines[1..]&.map { |l| " #{l}" }
173
+ [first, *rest].compact.join("\n")
174
+ else
175
+ "#{prefix}- #{item}"
176
+ end
177
+ end
178
+
179
+ # Determines which keys to display based on filtering options.
180
+ #
181
+ # @param all_keys [Array<String>] All available column names
182
+ # @param fields [Array<String>, nil] Explicit field selection
183
+ # @param all_fields [Boolean] Show all fields
184
+ # @return [Array<String>] Keys to display
185
+ def select_display_keys(all_keys, fields: nil, all_fields: false)
186
+ if fields
187
+ # Explicit field selection: only show requested fields that exist
188
+ fields.select { |f| all_keys.include?(f) }
189
+ elsif all_fields || all_keys.length <= DEFAULT_MAX_FIELDS
190
+ all_keys
191
+ else
192
+ # Default: show first N fields
193
+ all_keys.first(DEFAULT_MAX_FIELDS)
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end
@@ -0,0 +1,160 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'optparse'
4
+ require_relative 'formatter'
5
+ require_relative 'commands/base'
6
+ require_relative 'commands/api'
7
+ require_relative 'commands/backup'
8
+ require_relative 'commands/device'
9
+ require_relative 'commands/plugin'
10
+ require_relative 'commands/reconfigure'
11
+ require_relative 'commands/resource'
12
+
13
+ module OpnApi
14
+ module CLI
15
+ # Main CLI dispatcher. Parses global options, extracts the subcommand,
16
+ # and delegates to the appropriate command module.
17
+ module Main
18
+ COMMANDS = {
19
+ 'backup' => { handler: Commands::Backup.method(:download), desc: 'Download config backup' },
20
+ 'create' => { handler: Commands::Resource.method(:create), desc: 'Create resource' },
21
+ 'delete' => { handler: Commands::Resource.method(:delete), desc: 'Delete resource' },
22
+ 'devices' => { handler: Commands::Device.method(:list), desc: 'List configured devices' },
23
+ 'get' => { handler: Commands::Api.method(:get), desc: 'GET request to API path' },
24
+ 'groups' => { handler: Commands::Reconfigure.method(:groups), desc: 'List reconfigure groups' },
25
+ 'install' => { handler: Commands::Plugin.method(:install), desc: 'Install plugin' },
26
+ 'plugins' => { handler: Commands::Plugin.method(:list), desc: 'List installed plugins' },
27
+ 'post' => { handler: Commands::Api.method(:post), desc: 'POST request to API path' },
28
+ 'reconfigure' => { handler: Commands::Reconfigure.method(:run), desc: 'Trigger service reconfigure' },
29
+ 'resources' => { handler: Commands::Resource.method(:list), desc: 'List known resource types' },
30
+ 'search' => { handler: Commands::Resource.method(:search), desc: 'Search resources' },
31
+ 'show' => { handler: Commands::Resource.method(:show), desc: 'Show single resource' },
32
+ 'test' => { handler: Commands::Device.method(:test), desc: 'Test device connectivity' },
33
+ 'uninstall' => { handler: Commands::Plugin.method(:uninstall), desc: 'Uninstall plugin' },
34
+ 'update' => { handler: Commands::Resource.method(:update), desc: 'Update resource' },
35
+ }.freeze
36
+
37
+ module_function
38
+
39
+ # Main entry point for the CLI.
40
+ #
41
+ # @param argv [Array<String>] Command-line arguments
42
+ # @return [Integer] Exit code (0 = success, 1 = error)
43
+ def run(argv)
44
+ opts = parse_global_options(argv)
45
+
46
+ # Show help if no command given
47
+ if argv.empty?
48
+ show_help
49
+ return 0
50
+ end
51
+
52
+ command_name = argv.shift
53
+ command = COMMANDS[command_name]
54
+
55
+ unless command
56
+ warn("Unknown command: #{command_name}")
57
+ warn("Run 'opn-api --help' for usage information.")
58
+ return 1
59
+ end
60
+
61
+ # Configure logging level
62
+ OpnApi.logger = OpnApi::Logger.new(level: opts[:verbose] ? :debug : :info)
63
+
64
+ # Execute command and format output
65
+ result = command[:handler].call(argv, opts)
66
+ output = Formatter.format(result, format: opts[:format], fields: opts[:fields],
67
+ all_fields: opts[:all_fields],
68
+ show_empty: opts[:show_empty])
69
+ puts output unless output.nil? || output.empty?
70
+
71
+ 0
72
+ rescue OpnApi::ApiError => e
73
+ # Show full API response for debugging
74
+ warn("Error: #{e.message}")
75
+ warn("Response body: #{e.body}") if opts[:verbose] && e.body && !e.body.empty?
76
+ 1
77
+ rescue OpnApi::Error => e
78
+ warn("Error: #{e.message}")
79
+ 1
80
+ rescue JSON::ParserError => e
81
+ warn("Invalid JSON: #{e.message}")
82
+ 1
83
+ end
84
+
85
+ # Parses global options from argv (modifies argv in place).
86
+ def parse_global_options(argv)
87
+ opts = { device: 'default', format: :table, verbose: false }
88
+
89
+ parser = OptionParser.new do |o|
90
+ o.banner = 'Usage: opn-api [options] <command> [command-options] [args...]'
91
+ o.separator ''
92
+ o.separator 'Global options:'
93
+
94
+ o.on('-c', '--config-dir PATH', 'Config directory for device files') do |v|
95
+ opts[:config_dir] = v
96
+ end
97
+ o.on('-d', '--device NAME', 'Device name (default: "default")') do |v|
98
+ opts[:device] = v
99
+ end
100
+ o.on('-f', '--format FORMAT', %w[table json yaml],
101
+ 'Output format: table, json, yaml (default: table)') do |v|
102
+ opts[:format] = v.to_sym
103
+ end
104
+ o.on('-F', '--fields FIELDS', 'Comma-separated field names for table output') do |v|
105
+ opts[:fields] = v.split(',').map(&:strip)
106
+ end
107
+ o.on('-A', '--all-fields', 'Show all fields in table output (default: first 5)') do
108
+ opts[:all_fields] = true
109
+ end
110
+ o.on('-E', '--show-empty', 'Show empty fields in table output (default: hidden)') do
111
+ opts[:show_empty] = true
112
+ end
113
+ o.on('-v', '--verbose', 'Enable debug output') do
114
+ opts[:verbose] = true
115
+ end
116
+ o.on('--version', 'Show version') do
117
+ puts "opn-api #{OpnApi::VERSION}"
118
+ exit 0
119
+ end
120
+ o.on('-h', '--help', 'Show help') do
121
+ show_help
122
+ exit 0
123
+ end
124
+ end
125
+
126
+ # Parse global options from anywhere in argv (before or after command)
127
+ parser.parse!(argv)
128
+ opts
129
+ end
130
+
131
+ # Displays help with available commands.
132
+ def show_help
133
+ puts 'Usage: opn-api [options] <command> [command-options] [args...]'
134
+ puts ''
135
+ puts 'A CLI tool for the OPNsense REST API.'
136
+ puts ''
137
+ puts 'Global options:'
138
+ puts ' -c, --config-dir PATH Config directory for device files'
139
+ puts ' -d, --device NAME Device name (default: "default")'
140
+ puts ' -f, --format FORMAT Output format: table, json, yaml (default: table)'
141
+ puts ' -F, --fields FIELDS Comma-separated field names for table output'
142
+ puts ' -A, --all-fields Show all fields in table output (default: first 5)'
143
+ puts ' -E, --show-empty Show empty fields in table output (default: hidden)'
144
+ puts ' -v, --verbose Enable debug output (includes API error details)'
145
+ puts ' --version Show version'
146
+ puts ' -h, --help Show this help'
147
+ puts ''
148
+ puts 'Commands:'
149
+ max_len = COMMANDS.keys.map(&:length).max
150
+ COMMANDS.each do |name, cmd|
151
+ puts " #{name.ljust(max_len)} #{cmd[:desc]}"
152
+ end
153
+ puts ''
154
+ puts 'Resource commands (search, show, create, update, delete) accept a resource'
155
+ puts 'name from the registry (e.g. "haproxy_server") or raw module/controller/type.'
156
+ puts 'Run "opn-api resources" for a list of known resource types.'
157
+ end
158
+ end
159
+ end
160
+ end
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'json'
5
+ require 'uri'
6
+ require 'openssl'
7
+
8
+ module OpnApi
9
+ # HTTP client for communicating with the OPNsense REST API.
10
+ #
11
+ # Supports SSL, redirect following (301/302/307/308), configurable
12
+ # timeouts, and basic authentication with API key/secret.
13
+ #
14
+ # @example Direct instantiation
15
+ # client = OpnApi::Client.new(
16
+ # url: 'https://fw.example.com/api',
17
+ # api_key: '+ABC...', api_secret: '+XYZ...',
18
+ # ssl_verify: false,
19
+ # )
20
+ # result = client.get('firmware/info/running')
21
+ #
22
+ # @example From Config
23
+ # config = OpnApi::Config.new
24
+ # client = config.client_for('myfw')
25
+ class Client
26
+ DEFAULT_URL = 'http://localhost:80/api'
27
+ MAX_REDIRECTS = 5
28
+
29
+ # @param url [String] Base URL of the OPNsense API (e.g. 'https://fw.example.com/api')
30
+ # @param api_key [String] OPNsense API key
31
+ # @param api_secret [String] OPNsense API secret
32
+ # @param ssl_verify [Boolean] Whether to verify SSL certificates (default: true)
33
+ # @param timeout [Integer] HTTP timeout in seconds (default: 60)
34
+ def initialize(url:, api_key:, api_secret:, ssl_verify: true, timeout: 60)
35
+ @url = url.to_s.chomp('/')
36
+ @api_key = api_key
37
+ @api_secret = api_secret
38
+ @ssl_verify = ssl_verify
39
+ @timeout = timeout.to_i
40
+ end
41
+
42
+ # Performs an HTTP GET request to the OPNsense API.
43
+ #
44
+ # @param path [String] API path (relative, e.g. 'firewall/alias/search_item')
45
+ # @param raw [Boolean] If true, return raw response body instead of parsing JSON
46
+ # @return [Hash, String] Parsed JSON response, or raw body string when raw: true
47
+ def get(path, raw: false)
48
+ uri = build_uri(path)
49
+ OpnApi.logger.debug("GET #{uri}")
50
+ http_request(:get, uri, nil, 0, raw: raw)
51
+ end
52
+
53
+ # Performs an HTTP POST request to the OPNsense API.
54
+ #
55
+ # @param path [String] API path (relative)
56
+ # @param data [Hash] Request body (serialized as JSON)
57
+ # @return [Hash] Parsed JSON response
58
+ def post(path, data = {})
59
+ uri = build_uri(path)
60
+ OpnApi.logger.debug("POST #{uri} body=#{data.to_json}")
61
+ http_request(:post, uri, data)
62
+ end
63
+
64
+ private
65
+
66
+ # Executes an HTTP request, following redirects transparently.
67
+ # OPNsense commonly issues 308 redirects when HTTP is used but HTTPS is required.
68
+ #
69
+ # @param method [Symbol] :get or :post
70
+ # @param uri [URI] Fully qualified URI
71
+ # @param data [Hash, nil] POST body data
72
+ # @param redirect_count [Integer] Internal redirect counter
73
+ # @param raw [Boolean] If true, return raw response body instead of parsing JSON
74
+ # @return [Hash, String] Parsed JSON response, or raw body string when raw: true
75
+ def http_request(method, uri, data = nil, redirect_count = 0, raw: false)
76
+ if redirect_count > MAX_REDIRECTS
77
+ raise OpnApi::ConnectionError, "Too many redirects (> #{MAX_REDIRECTS}) for '#{uri}'"
78
+ end
79
+
80
+ http = build_http(uri)
81
+ request = build_request(method, uri, data)
82
+ request.basic_auth(@api_key, @api_secret)
83
+ request['Accept'] = 'application/json'
84
+ request['Content-Type'] = 'application/json' if method == :post
85
+
86
+ response = http.request(request)
87
+ code = response.code.to_i
88
+
89
+ # Handle redirects: 307/308 preserve method+body, 301/302 switch to GET
90
+ if [301, 302, 307, 308].include?(code)
91
+ location = response['location']
92
+ unless location
93
+ raise OpnApi::ConnectionError,
94
+ "#{code} redirect with no Location header for '#{uri}'"
95
+ end
96
+
97
+ OpnApi.logger.debug("Following #{code} redirect to '#{location}'")
98
+ new_uri = URI.parse(location)
99
+
100
+ if [307, 308].include?(code)
101
+ return http_request(method, new_uri, data, redirect_count + 1, raw: raw)
102
+ end
103
+
104
+ return http_request(:get, new_uri, nil, redirect_count + 1, raw: raw)
105
+ end
106
+
107
+ # Raw mode: skip JSON parsing, return body as string
108
+ return handle_raw_response(response, uri.to_s) if raw
109
+
110
+ handle_response(response, uri.to_s)
111
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ETIMEDOUT => e
112
+ raise OpnApi::ConnectionError, "Connection failed for '#{uri}': #{e.message}"
113
+ rescue Net::OpenTimeout, Net::ReadTimeout => e
114
+ raise OpnApi::TimeoutError, "Timeout for '#{uri}': #{e.message}"
115
+ end
116
+
117
+ # Builds a full URI from the base URL and a relative path.
118
+ def build_uri(path)
119
+ clean_path = path.to_s.sub(%r{^/+}, '')
120
+ URI.parse("#{@url}/#{clean_path}")
121
+ rescue URI::InvalidURIError => e
122
+ raise OpnApi::ConnectionError, "Invalid API path '#{path}': #{e.message}"
123
+ end
124
+
125
+ # Creates a Net::HTTP instance with SSL and timeout settings.
126
+ def build_http(uri)
127
+ http = Net::HTTP.new(uri.host, uri.port)
128
+ http.open_timeout = @timeout
129
+ http.read_timeout = @timeout
130
+
131
+ if uri.scheme == 'https'
132
+ http.use_ssl = true
133
+ http.verify_mode = @ssl_verify ? OpenSSL::SSL::VERIFY_PEER : OpenSSL::SSL::VERIFY_NONE
134
+ end
135
+
136
+ http
137
+ end
138
+
139
+ # Builds a Net::HTTP request object for the given method.
140
+ def build_request(method, uri, data)
141
+ case method
142
+ when :get
143
+ Net::HTTP::Get.new(uri)
144
+ when :post
145
+ req = Net::HTTP::Post.new(uri)
146
+ req.body = data.to_json
147
+ req
148
+ else
149
+ raise ArgumentError, "Unsupported HTTP method: #{method}"
150
+ end
151
+ end
152
+
153
+ # Returns raw response body after checking HTTP status.
154
+ # Used for non-JSON endpoints (e.g. XML backup downloads).
155
+ #
156
+ # @param response [Net::HTTPResponse]
157
+ # @param request_uri [String]
158
+ # @return [String] Raw response body
159
+ def handle_raw_response(response, request_uri)
160
+ code = response.code.to_i
161
+ unless (200..299).cover?(code)
162
+ raise OpnApi::ApiError.new(
163
+ "API error #{code} for '#{request_uri}': #{response.body}",
164
+ code: code,
165
+ body: response.body.to_s,
166
+ uri: request_uri,
167
+ )
168
+ end
169
+
170
+ OpnApi.logger.debug("Response #{code} (raw, #{response.body.to_s.bytesize} bytes)")
171
+ response.body.to_s
172
+ end
173
+
174
+ # Parses and validates an HTTP response.
175
+ #
176
+ # @param response [Net::HTTPResponse]
177
+ # @param request_uri [String]
178
+ # @return [Hash] Parsed JSON body
179
+ def handle_response(response, request_uri)
180
+ code = response.code.to_i
181
+ unless (200..299).cover?(code)
182
+ raise OpnApi::ApiError.new(
183
+ "API error #{code} for '#{request_uri}': #{response.body}",
184
+ code: code,
185
+ body: response.body.to_s,
186
+ uri: request_uri,
187
+ )
188
+ end
189
+
190
+ body = response.body
191
+ return {} if body.nil? || body.strip.empty?
192
+
193
+ OpnApi.logger.debug("Response #{code} body=#{body.strip}")
194
+ JSON.parse(body)
195
+ rescue JSON::ParserError => e
196
+ raise OpnApi::ApiError.new(
197
+ "Response parse error for '#{request_uri}': #{e.message}",
198
+ code: code,
199
+ body: body.to_s,
200
+ uri: request_uri,
201
+ )
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module OpnApi
6
+ # Configuration loader for OPNsense device credentials.
7
+ #
8
+ # Searches for per-device YAML files in a config directory hierarchy.
9
+ # Each YAML file contains connection details for one OPNsense device.
10
+ #
11
+ # Search order (lowest → highest priority):
12
+ # 1. /etc/opn-api/devices/ (system-wide)
13
+ # 2. ~/.config/opn-api/devices/ (per-user)
14
+ # 3. Explicit config_dir parameter (programmatic / CLI)
15
+ # 4. OPN_API_CONFIG_DIR env var (environment override)
16
+ #
17
+ # Device YAML format (compatible with puppet-opn):
18
+ # url: https://192.168.1.1/api
19
+ # api_key: +ABC...
20
+ # api_secret: +XYZ...
21
+ # ssl_verify: false
22
+ # timeout: 60
23
+ #
24
+ # @example
25
+ # config = OpnApi::Config.new
26
+ # config.device_names # => ['myfw', 'backup']
27
+ # client = config.client_for('myfw')
28
+ #
29
+ # @example With explicit path (e.g. for puppet-opn integration)
30
+ # config = OpnApi::Config.new(config_dir: '/etc/puppetlabs/puppet/opn')
31
+ # client = config.client_for('myfw')
32
+ class Config
33
+ SYSTEM_DIR = '/etc/opn-api/devices'
34
+ USER_DIR = File.join(Dir.home, '.config', 'opn-api', 'devices')
35
+
36
+ # @param config_dir [String, nil] Explicit device config directory.
37
+ # Overrides the default search hierarchy (but not OPN_API_CONFIG_DIR).
38
+ def initialize(config_dir: nil)
39
+ @config_dir = resolve_config_dir(config_dir)
40
+ end
41
+
42
+ # Returns all available device names found in the config directory.
43
+ #
44
+ # @return [Array<String>] Sorted list of device names
45
+ def device_names
46
+ return [] unless @config_dir && Dir.exist?(@config_dir)
47
+
48
+ Dir.glob(File.join(@config_dir, '*.yaml')).map do |f|
49
+ File.basename(f, '.yaml')
50
+ end.sort
51
+ end
52
+
53
+ # Returns the configuration hash for a named device.
54
+ #
55
+ # @param name [String] Device name (filename without .yaml extension)
56
+ # @return [Hash] Device config (url, api_key, api_secret, ssl_verify, timeout)
57
+ # @raise [OpnApi::ConfigError] if config file is missing or malformed
58
+ def device(name)
59
+ path = device_path(name)
60
+ unless File.exist?(path)
61
+ raise OpnApi::ConfigError,
62
+ "Config file not found for device '#{name}': #{path}"
63
+ end
64
+
65
+ config = YAML.safe_load_file(path)
66
+ unless config.is_a?(Hash)
67
+ raise OpnApi::ConfigError,
68
+ "Config file '#{path}' is not a valid YAML hash"
69
+ end
70
+
71
+ config
72
+ end
73
+
74
+ # Creates a Client instance for the named device.
75
+ #
76
+ # @param name [String] Device name
77
+ # @return [OpnApi::Client]
78
+ # @raise [OpnApi::ConfigError] if config is missing or malformed
79
+ def client_for(name)
80
+ cfg = device(name)
81
+ OpnApi::Client.new(
82
+ url: cfg['url'] || OpnApi::Client::DEFAULT_URL,
83
+ api_key: cfg['api_key'].to_s,
84
+ api_secret: cfg['api_secret'].to_s,
85
+ ssl_verify: cfg.fetch('ssl_verify', true),
86
+ timeout: cfg.fetch('timeout', 60),
87
+ )
88
+ end
89
+
90
+ # Returns the full path to a device config file.
91
+ #
92
+ # @param name [String] Device name
93
+ # @return [String]
94
+ def device_path(name)
95
+ File.join(@config_dir, "#{name}.yaml")
96
+ end
97
+
98
+ private
99
+
100
+ # Resolves the effective config directory from env, explicit arg, or defaults.
101
+ # Priority: OPN_API_CONFIG_DIR > explicit config_dir > first existing default dir
102
+ def resolve_config_dir(explicit_dir)
103
+ env_dir = ENV.fetch('OPN_API_CONFIG_DIR', nil)
104
+ return env_dir if env_dir && !env_dir.empty?
105
+ return explicit_dir if explicit_dir
106
+
107
+ # Use the first default directory that exists, or fall back to USER_DIR
108
+ [USER_DIR, SYSTEM_DIR].find { |d| Dir.exist?(d) } || USER_DIR
109
+ end
110
+ end
111
+ end