Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions lib/minisky/errors.rb
Original file line number Diff line number Diff line change
@@ -1,18 +1,27 @@
require_relative 'minisky'

class Minisky
# Base error class for Minisky.
class Error < StandardError
end

# Raised when authentication or credentials are invalid.
class AuthError < Error
# @param message [String]
def initialize(message)
super(message)
end
end

# Raised when the API returns a non-success response.
class BadResponse < Error
# @return [Integer] HTTP status code
# @return [Object] parsed response data
attr_reader :status, :data

# @param status [Integer]
# @param status_message [String]
# @param data [Object]
def initialize(status, status_message, data)
@status = status
@data = data
Expand All @@ -26,36 +35,49 @@ def initialize(status, status_message, data)
super(message)
end

# @return [String, nil] error type from response data
def error_type
@data['error'] if @data.is_a?(Hash)
end

# @return [String, nil] error message from response data
def error_message
@data['message'] if @data.is_a?(Hash)
end
end

# Client error response (4xx).
class ClientErrorResponse < BadResponse
end

# Server error response (5xx).
class ServerErrorResponse < BadResponse
end

# Expired access token response.
class ExpiredTokenError < ClientErrorResponse
end

# Raised when a redirect is encountered unexpectedly.
class UnexpectedRedirect < BadResponse
# @return [String] redirect location
attr_reader :location

# @param status [Integer]
# @param status_message [String]
# @param location [String]
def initialize(status, status_message, location)
super(status, status_message, { 'message' => "Unexpected redirect: #{location}" })
@location = location
end
end

# Raised when fetch_all cannot determine the response field.
class FieldNotSetError < Error
# @return [Array<String>]
attr_reader :fields

# @param fields [Array<String>]
def initialize(fields)
@fields = fields
super("Field parameter not provided; available fields: #{@fields.inspect}")
Expand Down
15 changes: 15 additions & 0 deletions lib/minisky/minisky.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
require 'yaml'

# Main client for interacting with AT Protocol servers.
class Minisky
# @return [String] the host name or base URL for the server
# @return [Hash] the loaded configuration data
attr_reader :host, :config

# Create a new client instance.
#
# @param host [String] the host name or base URL for the server
# @param config_file [String, nil] path to the YAML config file
# @param options [Hash] optional attribute overrides to apply
# @raise [AuthError] if the config file is missing required credentials
def initialize(host, config_file, options = {})
@host = host
@config_file = config_file
Expand Down Expand Up @@ -30,12 +39,18 @@ def initialize(host, config_file, options = {})
end
end

# Check whether the current process looks like an interactive REPL.
#
# @return [Boolean] true when running inside IRB or Pry
def active_repl?
return true if defined?(IRB) && IRB.respond_to?(:CurrentContext) && IRB.CurrentContext
return true if defined?(Pry) && Pry.respond_to?(:cli) && Pry.cli
false
end

# Persist the current configuration to disk.
#
# @return [void]
def save_config
File.write(@config_file, YAML.dump(@config)) if @config_file
end
Expand Down
104 changes: 104 additions & 0 deletions lib/minisky/requests.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,23 @@
require 'uri'

class Minisky
# Lightweight wrapper around a mutable configuration hash.
class User
# @param config [Hash] backing configuration hash
def initialize(config)
@config = config
end

# @return [Boolean] whether the user has both access and refresh tokens
def logged_in?
!!(access_token && refresh_token)
end

# Forward unknown getters/setters to the config hash.
#
# @param name [Symbol]
# @param args [Array]
# @return [Object]
def method_missing(name, *args)
if name.to_s.end_with?('=')
@config[name.to_s.chop] = args[0]
Expand All @@ -26,24 +34,34 @@ def method_missing(name, *args)
end
end

# Regular expression that matches AT Protocol NSID identifiers.
NSID_REGEXP = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z]{0,61}[a-zA-Z])?)$/

# HTTP request helpers for the Minisky client.
module Requests
# @return [String, nil] character printed for progress indication
attr_accessor :default_progress
# @return [Boolean] whether to send auth headers automatically
attr_writer :send_auth_headers
# @return [Boolean] whether to automatically manage access tokens
attr_writer :auto_manage_tokens

# @return [Boolean] whether auth headers are enabled
def send_auth_headers
instance_variable_defined?('@send_auth_headers') ? @send_auth_headers : true
end

# @return [Boolean] whether token management is enabled
def auto_manage_tokens
instance_variable_defined?('@auto_manage_tokens') ? @auto_manage_tokens : true
end

alias progress default_progress
alias progress= default_progress=

# Build the base XRPC URL for this client.
#
# @return [String]
def base_url
if host.include?('://')
host.chomp('/') + '/xrpc'
Expand All @@ -52,10 +70,19 @@ def base_url
end
end

# @return [Minisky::User] accessor for configuration-backed user data
def user
@user ||= User.new(config)
end

# Perform a GET request.
#
# @param method [String, URI] NSID name or URI
# @param params [Hash, nil] query parameters
# @param auth [Boolean, String] whether to use auth headers or bearer token string
# @param headers [Hash, nil] extra headers to include
# @return [Hash, String] parsed JSON or raw response body
# @raise [AuthError, BadResponse]
def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
check_access if auto_manage_tokens && auth == true

Expand All @@ -72,6 +99,15 @@ def get_request(method, params = nil, auth: default_auth_mode, headers: nil)
handle_response(response)
end

# Perform a POST request.
#
# @param method [String, URI] NSID name or URI
# @param data [Hash, String, nil] JSON payload
# @param auth [Boolean, String] whether to use auth headers or bearer token string
# @param headers [Hash, nil] extra headers to include
# @param params [Hash, nil] query parameters
# @return [Hash, String] parsed JSON or raw response body
# @raise [AuthError, BadResponse]
def post_request(method, data = nil, auth: default_auth_mode, headers: nil, params: nil)
check_access if auto_manage_tokens && auth == true

Expand All @@ -94,6 +130,18 @@ def post_request(method, data = nil, auth: default_auth_mode, headers: nil, para
handle_response(response)
end

# Fetch paginated records until the cursor ends or a break condition is met.
#
# @param method [String, URI] NSID name or URI
# @param params [Hash, nil] query parameters
# @param auth [Boolean, String] whether to use auth headers or bearer token string
# @param field [String, Symbol, nil] response field containing the records array
# @param break_when [Proc, nil] optional predicate to stop when any record matches
# @param max_pages [Integer, nil] maximum number of pages to request
# @param headers [Hash, nil] extra headers to include
# @param progress [String, nil] progress indicator printed each request
# @return [Array] collected records
# @raise [FieldNotSetError]
def fetch_all(method, params = nil, auth: default_auth_mode,
field: nil, break_when: nil, max_pages: nil, headers: nil, progress: @default_progress)
data = []
Expand Down Expand Up @@ -124,6 +172,9 @@ def fetch_all(method, params = nil, auth: default_auth_mode,
data
end

# Ensure access tokens are valid when auto-management is enabled.
#
# @return [Symbol] :logged_in, :refreshed, or :ok
def check_access
if !user.logged_in?
log_in
Expand All @@ -136,6 +187,10 @@ def check_access
end
end

# Authenticate with the server and store tokens.
#
# @return [Hash] response JSON
# @raise [AuthError]
def log_in
if user.id.nil? || user.pass.nil?
raise AuthError, "To log in, please provide a user id and password"
Expand All @@ -156,6 +211,10 @@ def log_in
json
end

# Refresh the access token using the stored refresh token.
#
# @return [Hash] response JSON
# @raise [AuthError]
def perform_token_refresh
if user.refresh_token.nil?
raise AuthError, "Can't refresh access token - refresh token is missing"
Expand All @@ -170,6 +229,11 @@ def perform_token_refresh
json
end

# Parse the access token expiry from a JWT.
#
# @param token [String] JWT access token
# @return [Time] expiration time
# @raise [AuthError]
def token_expiration_date(token)
parts = token.split('.')
raise AuthError, "Invalid access token format" unless parts.length == 3
Expand All @@ -186,10 +250,14 @@ def token_expiration_date(token)
Time.at(exp)
end

# @return [Boolean] whether the access token expires within 60 seconds
def access_token_expired?
token_expiration_date(user.access_token) < Time.now + 60
end

# Clear stored access and refresh tokens.
#
# @return [nil]
def reset_tokens
user.access_token = nil
user.refresh_token = nil
Expand All @@ -202,11 +270,27 @@ def reset_tokens
alias_method :do_post_request, :post_request
private :do_get_request, :do_post_request

# Backwards-compatible keyword support for Ruby 2.
#
# @param method [String, URI] NSID name or URI
# @param params [Hash, nil] query parameters
# @param auth [Boolean, String] whether to use auth headers or bearer token string
# @param headers [Hash, nil] extra headers to include
# @param kwargs [Hash] keyword params for older callers
# @return [Hash, String] parsed JSON or raw response body
def get_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
params ||= kwargs unless kwargs.empty?
do_get_request(method, params, auth: auth, headers: headers)
end

# Backwards-compatible keyword support for Ruby 2.
#
# @param method [String, URI] NSID name or URI
# @param params [Hash, nil] JSON payload
# @param auth [Boolean, String] whether to use auth headers or bearer token string
# @param headers [Hash, nil] extra headers to include
# @param kwargs [Hash] keyword params for older callers
# @return [Hash, String] parsed JSON or raw response body
def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **kwargs)
params ||= kwargs unless kwargs.empty?
do_post_request(method, params, auth: auth, headers: headers)
Expand All @@ -216,13 +300,22 @@ def post_request(method, params = nil, auth: default_auth_mode, headers: nil, **

private

# Execute the HTTP request and return the raw response.
#
# @param request [Net::HTTPRequest]
# @return [Net::HTTPResponse]
def make_request(request)
# this long form is needed because #get_response only supports a headers param in Ruby 3.x
response = Net::HTTP.start(request.uri.hostname, request.uri.port, use_ssl: true) do |http|
http.request(request)
end
end

# Build a request URI from an NSID, URL, or URI object.
#
# @param method [String, URI]
# @return [URI]
# @raise [ArgumentError]
def build_request_uri(method)
if method.is_a?(URI)
method
Expand All @@ -235,10 +328,16 @@ def build_request_uri(method)
end
end

# @return [Boolean] default auth mode
def default_auth_mode
!!send_auth_headers
end

# Build the authorization header for a request.
#
# @param auth [Boolean, String]
# @return [Hash]
# @raise [AuthError]
def authentication_header(auth)
if auth.is_a?(String)
{ 'Authorization' => "Bearer #{auth}" }
Expand All @@ -253,6 +352,11 @@ def authentication_header(auth)
end
end

# Raise errors or return parsed response bodies.
#
# @param response [Net::HTTPResponse]
# @return [Hash, String]
# @raise [BadResponse]
def handle_response(response)
status = response.code.to_i
message = response.message
Expand Down
2 changes: 2 additions & 0 deletions lib/minisky/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
require_relative 'minisky'

class Minisky
# Current gem version.
# @return [String]
VERSION = "0.5.0"
end
Loading
Loading