diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c7e632..c978915 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ ## [Unreleased] +## [1.0.0] + +### Added + +- `LiveKit::LiveKitAPI`, a single entry point exposing every service through a reader (`room`, `egress`, `ingress`, `sip`, `agent_dispatch`, `connector`). +- Token authentication: construct clients (or `LiveKitAPI`) with a pre-signed `token:` that is sent verbatim, enabling client-side use without an API secret. Credentials fall back to `LIVEKIT_URL`, `LIVEKIT_TOKEN`, `LIVEKIT_API_KEY`, and `LIVEKIT_API_SECRET`. +- `LiveKit::SipCallError` (a `LiveKit::TwirpError`) raised by SIP dialing calls, exposing `sip_status_code` and `sip_status`. + +### Changed + +- **Breaking:** service methods now return the response message directly and raise `LiveKit::TwirpError` on failure, instead of returning a `Twirp::ClientResp`. Replace `resp = client.create_room(...); resp.data` with `room = client.create_room(...)`, and rescue `LiveKit::TwirpError` for errors. +- `faraday` is now a declared runtime dependency. + +### Fixed + +- `EgressServiceClient` no longer raises when a single output is passed to `start_participant_egress` (the request has no deprecated singular output field). +- `AgentDispatchServiceClient#get_dispatch` / `#list_dispatch` now return correctly (they previously assumed the RPC returned data directly). + ## [0.1.0] - 2021-07-25 - Initial release diff --git a/Gemfile.lock b/Gemfile.lock index 6ec377e..2d55eba 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,8 @@ PATH remote: . specs: - livekit-server-sdk (0.9.0) + livekit-server-sdk (1.0.0) + faraday (>= 2.0, < 3.0) google-protobuf (~> 4.30, >= 4.30.2) jwt (>= 2.2.3, < 3.0) twirp (~> 1.13, >= 1.13.1) diff --git a/lib/livekit.rb b/lib/livekit.rb index 13b91a7..492626c 100644 --- a/lib/livekit.rb +++ b/lib/livekit.rb @@ -3,6 +3,7 @@ require "livekit/access_token" require "livekit/utils" require "livekit/grants" +require "livekit/errors" require "livekit/token_verifier" require "livekit/version" @@ -14,3 +15,4 @@ require "livekit/sip_service_client" require "livekit/agent_dispatch_service_client" require "livekit/connector_service_client" +require "livekit/livekit_api" diff --git a/lib/livekit/agent_dispatch_service_client.rb b/lib/livekit/agent_dispatch_service_client.rb index cad8e46..c136420 100644 --- a/lib/livekit/agent_dispatch_service_client.rb +++ b/lib/livekit/agent_dispatch_service_client.rb @@ -11,10 +11,11 @@ class AgentDispatchServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end # Creates a new agent dispatch for a named agent @@ -35,7 +36,7 @@ def create_dispatch( agent_name: agent_name, metadata: metadata, ) - self.rpc( + rpc!( :CreateDispatch, request, headers: auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name)), @@ -51,7 +52,7 @@ def delete_dispatch(dispatch_id, room_name) dispatch_id: dispatch_id, room: room_name, ) - self.rpc( + rpc!( :DeleteDispatch, request, headers: auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name)), @@ -67,7 +68,7 @@ def get_dispatch(dispatch_id, room_name) dispatch_id: dispatch_id, room: room_name, ) - res = self.rpc( + res = rpc!( :ListDispatch, request, headers: auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name)), @@ -85,7 +86,7 @@ def list_dispatch(room_name) request = Proto::ListAgentDispatchRequest.new( room: room_name, ) - res = self.rpc( + res = rpc!( :ListDispatch, request, headers: auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name)), diff --git a/lib/livekit/auth_mixin.rb b/lib/livekit/auth_mixin.rb index 71f0f9e..052430c 100644 --- a/lib/livekit/auth_mixin.rb +++ b/lib/livekit/auth_mixin.rb @@ -1,23 +1,38 @@ # frozen_string_literal: true +require "livekit/errors" + module LiveKit - # Create authenticated headers when keys are provided + # Shared behavior for the Twirp-based service clients: builds authenticated + # request headers and issues RPCs that raise on error. module AuthMixin + # Builds request headers. When a pre-signed token is set it is sent verbatim + # (the caller is responsible for its grants); otherwise a token is signed per + # call from the API key and secret. def auth_header( video_grant: nil, sip_grant: nil ) headers = {} - t = ::LiveKit::AccessToken.new(api_key: @api_key, api_secret: @api_secret) - if video_grant != nil - t.video_grant = video_grant - end - if sip_grant != nil - t.sip_grant = sip_grant + if @token + headers["Authorization"] = "Bearer #{@token}" + return headers end + + t = ::LiveKit::AccessToken.new(api_key: @api_key, api_secret: @api_secret) + t.video_grant = video_grant if video_grant != nil + t.sip_grant = sip_grant if sip_grant != nil headers["Authorization"] = "Bearer #{t.to_jwt}" - headers["User-Agent"] = "LiveKit Ruby SDK" headers end + + # Issues a Twirp RPC and returns the response message, raising a + # {LiveKit::ServerError} (or {LiveKit::SipCallError}) on failure. + def rpc!(rpc_method, input, headers:) + resp = rpc(rpc_method, input, headers: headers) + raise ::LiveKit::ServerError.from(resp.error) if resp.error + + resp.data + end end end diff --git a/lib/livekit/connector_service_client.rb b/lib/livekit/connector_service_client.rb index f89a718..5dc940f 100644 --- a/lib/livekit/connector_service_client.rb +++ b/lib/livekit/connector_service_client.rb @@ -15,17 +15,18 @@ class ConnectorServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end # Initiates an outbound WhatsApp call. # @param request [Proto::DialWhatsAppCallRequest] # @return [Proto::DialWhatsAppCallResponse] def dial_whatsapp_call(request) - self.rpc( + rpc!( :DialWhatsAppCall, request, headers: auth_header(video_grant: VideoGrant.new(roomCreate: true)), @@ -35,28 +36,26 @@ def dial_whatsapp_call(request) # Accepts an inbound WhatsApp call. # @param request [Proto::AcceptWhatsAppCallRequest] # @param timeout [Numeric, nil] optional request timeout in seconds. When the - # request waits for an answer, it defaults to a longer value (dialing takes - # time) and is raised, if needed, to stay above the request's ringing_timeout. + # request waits for the inbound party to join, it defaults to the standard + # ring window. # @return [Proto::AcceptWhatsAppCallResponse] def accept_whatsapp_call(request, timeout: nil) headers = auth_header(video_grant: VideoGrant.new(roomCreate: true)) - # Accept can block until the call is answered, so default the request timeout - # to the standard ring window; otherwise honor any user timeout. The caller - # overrides via +timeout+ and should set it above the ringing_timeout passed - # to dial_whatsapp_call (the two calls are separate). + # Waiting for the inbound party to join can block, so default the request + # timeout to the standard ring window; otherwise honor any user timeout. if request.wait_until_answered headers[Failover::TIMEOUT_HEADER] = (timeout || DialTimeout::DEFAULT_RINGING_TIMEOUT).to_s elsif timeout headers[Failover::TIMEOUT_HEADER] = timeout.to_s end - self.rpc(:AcceptWhatsAppCall, request, headers: headers) + rpc!(:AcceptWhatsAppCall, request, headers: headers) end # Connects an established WhatsApp call (used for business-initiated calls). # @param request [Proto::ConnectWhatsAppCallRequest] # @return [Proto::ConnectWhatsAppCallResponse] def connect_whatsapp_call(request) - self.rpc( + rpc!( :ConnectWhatsAppCall, request, headers: auth_header(video_grant: VideoGrant.new(roomCreate: true)), @@ -67,7 +66,7 @@ def connect_whatsapp_call(request) # @param request [Proto::DisconnectWhatsAppCallRequest] # @return [Proto::DisconnectWhatsAppCallResponse] def disconnect_whatsapp_call(request) - self.rpc( + rpc!( :DisconnectWhatsAppCall, request, headers: auth_header(video_grant: VideoGrant.new(roomCreate: true)), @@ -78,7 +77,7 @@ def disconnect_whatsapp_call(request) # @param request [Proto::ConnectTwilioCallRequest] # @return [Proto::ConnectTwilioCallResponse] def connect_twilio_call(request) - self.rpc( + rpc!( :ConnectTwilioCall, request, headers: auth_header(video_grant: VideoGrant.new(roomCreate: true)), diff --git a/lib/livekit/dial_timeout.rb b/lib/livekit/dial_timeout.rb index d67101e..dee2b8e 100644 --- a/lib/livekit/dial_timeout.rb +++ b/lib/livekit/dial_timeout.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true module LiveKit - # Request-timeout handling shared by calls that dial a phone and wait for an - # answer (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp + # Request-timeout handling shared by calls that may block until a call is + # answered (SIP CreateSIPParticipant/TransferSIPParticipant, WhatsApp # AcceptWhatsAppCall). These take longer than a normal request, and the request - # must outlast ringing or it would abort before the call can be answered. + # must outlast the wait or it would abort before the call can be answered. module DialTimeout # Ring window (seconds) assumed when a request doesn't set a ringing timeout; # matches the server default. A dialing request must outlast it. diff --git a/lib/livekit/egress_service_client.rb b/lib/livekit/egress_service_client.rb index 157e800..d1d2f59 100644 --- a/lib/livekit/egress_service_client.rb +++ b/lib/livekit/egress_service_client.rb @@ -11,10 +11,11 @@ class EgressServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end def start_room_composite_egress( @@ -50,7 +51,7 @@ def start_room_composite_egress( webhooks: webhooks, ) self.set_output(request, output) - self.rpc( + rpc!( :StartRoomCompositeEgress, request, headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -80,7 +81,7 @@ def start_participant_egress( webhooks: webhooks, ) self.set_output(request, output) - self.rpc( + rpc!( :StartParticipantEgress, request, headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -111,7 +112,7 @@ def start_track_composite_egress( webhooks: webhooks, ) self.set_output(request, output) - self.rpc( + rpc!( :StartTrackCompositeEgress, request, headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -136,7 +137,7 @@ def start_track_egress( else request.websocket_url = output end - self.rpc( + rpc!( :StartTrackEgress, request, headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -170,7 +171,7 @@ def start_web_egress( webhooks: webhooks, ) self.set_output(request, output) - self.rpc( + rpc!( :StartWebEgress, request, headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -178,7 +179,7 @@ def start_web_egress( end def update_layout(egress_id, layout) - self.rpc( + rpc!( :UpdateLayout, Proto::UpdateLayoutRequest.new( egress_id: egress_id, @@ -192,7 +193,7 @@ def update_stream(egress_id, add_output_urls: [], remove_output_urls: [] ) - self.rpc( + rpc!( :UpdateStream, Proto::UpdateStreamRequest.new( egress_id: egress_id, @@ -209,7 +210,7 @@ def list_egress( egress_id: nil, active: false ) - self.rpc( + rpc!( :ListEgress, Proto::ListEgressRequest.new( room_name: room_name, @@ -221,7 +222,7 @@ def list_egress( end def stop_egress(egress_id) - self.rpc( + rpc!( :StopEgress, Proto::StopEgressRequest.new(egress_id: egress_id), headers:auth_header(video_grant: VideoGrant.new(roomRecord: true)), @@ -253,13 +254,15 @@ def set_output(request, output) end end elsif output.is_a? LiveKit::Proto::EncodedFileOutput - request.file = output + # The singular field is deprecated and absent on some requests (e.g. + # ParticipantEgressRequest); the plural *_outputs is the current field. + request.file = output if request.respond_to?(:file=) request.file_outputs = Google::Protobuf::RepeatedField.new(:message, Proto::EncodedFileOutput, [output]) elsif output.is_a? LiveKit::Proto::SegmentedFileOutput - request.segments = output + request.segments = output if request.respond_to?(:segments=) request.segment_outputs = Google::Protobuf::RepeatedField.new(:message, Proto::SegmentedFileOutput, [output]) elsif output.is_a? LiveKit::Proto::StreamOutput - request.stream = output + request.stream = output if request.respond_to?(:stream=) request.stream_outputs = Google::Protobuf::RepeatedField.new(:message, Proto::StreamOutput, [output]) elsif output.is_a? LiveKit::Proto::ImageOutput request.image_outputs = Google::Protobuf::RepeatedField.new(:message, Proto::ImageOutput, [output]) diff --git a/lib/livekit/errors.rb b/lib/livekit/errors.rb new file mode 100644 index 0000000..b714275 --- /dev/null +++ b/lib/livekit/errors.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module LiveKit + # Raised when a LiveKit server API call fails. Exposes the error code, message, + # and any metadata the server attached. + class ServerError < StandardError + # @return [String] the error code, e.g. "not_found" or "permission_denied" + attr_reader :code + # @return [Hash{String=>String}] error metadata returned by the server + attr_reader :metadata + + def initialize(code, message, metadata: {}) + @code = code + @metadata = metadata || {} + super(message) + end + + # Builds a ServerError (or SipCallError) from the underlying error. Returns nil + # when +err+ is nil, so it can be called directly on a response's error. + # @return [LiveKit::ServerError, nil] + def self.from(err) + return nil if err.nil? + + meta = err.meta || {} + klass = meta.key?("sip_status_code") ? SipCallError : self + klass.new(err.code.to_s, err.msg, metadata: meta) + end + end + + # Raised when a SIP dialing call (create_sip_participant / transfer_sip_participant) + # fails with a SIP response status. The SIP code and reason are exposed + # directly; any other metadata remains available via {#metadata}. + class SipCallError < ServerError + # @return [Integer, nil] the SIP response code, e.g. 486 (Busy Here) + def sip_status_code + raw = metadata["sip_status_code"] + raw && raw.to_i + end + + # @return [String, nil] the SIP reason phrase, e.g. "Busy Here" + def sip_status + metadata["sip_status"] + end + + # A clear, SIP-specific representation, including any extra metadata. + def message + code_str = metadata["sip_status_code"] + return super if code_str.nil? + + reason = metadata["sip_status"] + result = "SIP call failed: #{code_str}" + result += " #{reason}" if reason && !reason.empty? + result += " (#{code})" + extra = metadata.reject { |k, _| %w[sip_status_code sip_status error_details].include?(k) } + result += " [#{extra.map { |k, v| "#{k}=#{v}" }.join(", ")}]" unless extra.empty? + result + end + alias to_s message + end +end diff --git a/lib/livekit/failover.rb b/lib/livekit/failover.rb index 67eefa2..46a2ecc 100644 --- a/lib/livekit/failover.rb +++ b/lib/livekit/failover.rb @@ -5,6 +5,7 @@ require 'set' require 'uri' require 'livekit/utils' +require 'livekit/version' module LiveKit # Region failover for the Twirp API clients. @@ -48,6 +49,7 @@ def self.attempts(enabled, host, force, timeout) def self.connection(base_url, failover) url = File.join(Utils.to_http_url(base_url), '/twirp') Faraday.new(url: url) do |f| + f.headers['User-Agent'] = "livekit-server-sdk-ruby/#{VERSION}" f.options.timeout = DEFAULT_TIMEOUT f.use RegionFailoverMiddleware, failover: failover f.adapter Faraday.default_adapter diff --git a/lib/livekit/ingress_service_client.rb b/lib/livekit/ingress_service_client.rb index bf8eedb..762dc20 100644 --- a/lib/livekit/ingress_service_client.rb +++ b/lib/livekit/ingress_service_client.rb @@ -9,10 +9,11 @@ class IngressServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end def create_ingress( @@ -49,7 +50,7 @@ def create_ingress( enable_transcoding: enable_transcoding, url: url, ) - self.rpc( + rpc!( :CreateIngress, request, headers:auth_header(video_grant: VideoGrant.new(ingressAdmin: true)), @@ -87,7 +88,7 @@ def update_ingress( bypass_transcoding: bypass_transcoding, enable_transcoding: enable_transcoding, ) - self.rpc( + rpc!( :UpdateIngress, request, headers:auth_header(video_grant: VideoGrant.new(ingressAdmin: true)), @@ -104,7 +105,7 @@ def list_ingress( room_name: room_name, ingress_id: ingress_id, ) - self.rpc( + rpc!( :ListIngress, request, headers:auth_header(video_grant: VideoGrant.new(ingressAdmin: true)), @@ -115,7 +116,7 @@ def delete_ingress(ingress_id) request = Proto::DeleteIngressRequest.new( ingress_id: ingress_id ) - self.rpc( + rpc!( :DeleteIngress, request, headers:auth_header(video_grant: VideoGrant.new(ingressAdmin: true)), diff --git a/lib/livekit/livekit_api.rb b/lib/livekit/livekit_api.rb new file mode 100644 index 0000000..da3df62 --- /dev/null +++ b/lib/livekit/livekit_api.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "livekit/room_service_client" +require "livekit/egress_service_client" +require "livekit/ingress_service_client" +require "livekit/sip_service_client" +require "livekit/agent_dispatch_service_client" +require "livekit/connector_service_client" +require "livekit/failover" + +module LiveKit + # A single entry point to every LiveKit server API, exposing each service + # through a reader. + # + # @example + # api = LiveKit::LiveKitAPI.new("https://my.livekit.host", api_key: k, api_secret: s) + # api.room.create_room("my-room") + class LiveKitAPI + # @return [RoomServiceClient] + attr_reader :room + # @return [EgressServiceClient] + attr_reader :egress + # @return [IngressServiceClient] + attr_reader :ingress + # @return [SIPServiceClient] + attr_reader :sip + # @return [AgentDispatchServiceClient] + attr_reader :agent_dispatch + # @return [ConnectorServiceClient] + attr_reader :connector + + # Authenticate with either an API key and secret (recommended for backend + # use), or a pre-signed +token+ (for client-side use, where the API secret + # must not be exposed). Any omitted value falls back to its environment + # variable: +LIVEKIT_URL+, +LIVEKIT_TOKEN+, +LIVEKIT_API_KEY+, +LIVEKIT_API_SECRET+. + def initialize(url = nil, api_key: nil, api_secret: nil, token: nil, failover: true) + url ||= ENV["LIVEKIT_URL"] + token ||= ENV["LIVEKIT_TOKEN"] + api_key ||= ENV["LIVEKIT_API_KEY"] + api_secret ||= ENV["LIVEKIT_API_SECRET"] + + raise ArgumentError, "url is required (pass it or set LIVEKIT_URL)" if url.nil? || url.empty? + if token.nil? && (api_key.nil? || api_secret.nil?) + raise ArgumentError, "either a token, or api_key and api_secret, are required" + end + + # Share one Faraday connection (and its adapter/middleware) across every + # service instead of letting each open its own. + connection = LiveKit::Failover.connection(url, failover) + opts = { api_key: api_key, api_secret: api_secret, token: token, failover: failover, connection: connection } + @room = RoomServiceClient.new(url, **opts) + @egress = EgressServiceClient.new(url, **opts) + @ingress = IngressServiceClient.new(url, **opts) + @sip = SIPServiceClient.new(url, **opts) + @agent_dispatch = AgentDispatchServiceClient.new(url, **opts) + @connector = ConnectorServiceClient.new(url, **opts) + end + end +end diff --git a/lib/livekit/room_service_client.rb b/lib/livekit/room_service_client.rb index 7986fed..52ae406 100644 --- a/lib/livekit/room_service_client.rb +++ b/lib/livekit/room_service_client.rb @@ -13,10 +13,11 @@ class RoomServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end def create_room(name, @@ -29,7 +30,7 @@ def create_room(name, sync_streams: nil, departure_timeout: nil ) - self.rpc( + rpc!( :CreateRoom, Proto::CreateRoomRequest.new( name: name, @@ -47,7 +48,7 @@ def create_room(name, end def list_rooms(names: nil) - self.rpc( + rpc!( :ListRooms, Proto::ListRoomsRequest.new(names: names), headers:auth_header(video_grant: VideoGrant.new(roomList: true)), @@ -55,7 +56,7 @@ def list_rooms(names: nil) end def delete_room(room:) - self.rpc( + rpc!( :DeleteRoom, Proto::DeleteRoomRequest.new(room: room), headers:auth_header(video_grant: VideoGrant.new(roomCreate: true)), @@ -63,7 +64,7 @@ def delete_room(room:) end def update_room_metadata(room:, metadata:) - self.rpc( + rpc!( :UpdateRoomMetadata, Proto::UpdateRoomMetadataRequest.new(room: room, metadata: metadata), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room)), @@ -71,7 +72,7 @@ def update_room_metadata(room:, metadata:) end def list_participants(room:) - self.rpc( + rpc!( :ListParticipants, Proto::ListParticipantsRequest.new(room: room), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room)), @@ -79,7 +80,7 @@ def list_participants(room:) end def get_participant(room:, identity:) - self.rpc( + rpc!( :GetParticipant, Proto::RoomParticipantIdentity.new(room: room, identity: identity), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room)), @@ -87,7 +88,7 @@ def get_participant(room:, identity:) end def remove_participant(room:, identity:) - self.rpc( + rpc!( :RemoveParticipant, Proto::RoomParticipantIdentity.new(room: room, identity: identity), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room)), @@ -95,7 +96,7 @@ def remove_participant(room:, identity:) end def forward_participant(room:, identity:, destination_room:) - self.rpc( + rpc!( :ForwardParticipant, Proto::ForwardParticipantRequest.new(room: room, identity: identity, destination_room: destination_room), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room, destinationRoom: destination_room)), @@ -103,7 +104,7 @@ def forward_participant(room:, identity:, destination_room:) end def move_participant(room:, identity:, destination_room:) - self.rpc( + rpc!( :MoveParticipant, Proto::MoveParticipantRequest.new(room: room, identity: identity, destination_room: destination_room), headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room, destinationRoom: destination_room)), @@ -111,7 +112,7 @@ def move_participant(room:, identity:, destination_room:) end def mute_published_track(room:, identity:, track_sid:, muted:) - self.rpc( + rpc!( :MutePublishedTrack, Proto::MuteRoomTrackRequest.new( room: room, @@ -148,7 +149,7 @@ def update_participant( end req.attributes = attr_map end - self.rpc( + rpc!( :UpdateParticipant, req, headers:auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room)), @@ -156,7 +157,7 @@ def update_participant( end def update_subscriptions(room:, identity:, track_sids:, subscribe:) - self.rpc( + rpc!( :UpdateSubscriptions, Proto::UpdateSubscriptionsRequest.new( room: room, @@ -172,7 +173,7 @@ def send_data(room:, data:, kind:, destination_sids: [], destination_identities: [] ) - self.rpc( + rpc!( :SendData, Proto::SendDataRequest.new( room: room, diff --git a/lib/livekit/sip_service_client.rb b/lib/livekit/sip_service_client.rb index a9920c9..2b69191 100644 --- a/lib/livekit/sip_service_client.rb +++ b/lib/livekit/sip_service_client.rb @@ -10,10 +10,11 @@ class SIPServiceClient < Twirp::Client include AuthMixin attr_accessor :api_key, :api_secret - def initialize(base_url, api_key: nil, api_secret: nil, failover: true) - super(LiveKit::Failover.connection(base_url, failover)) + def initialize(base_url, api_key: nil, api_secret: nil, token: nil, failover: true, connection: nil) + super(connection || LiveKit::Failover.connection(base_url, failover)) @api_key = api_key @api_secret = api_secret + @token = token end def create_sip_inbound_trunk( @@ -54,7 +55,7 @@ def create_sip_inbound_trunk( krisp_enabled: krisp_enabled ) ) - self.rpc( + rpc!( :CreateSIPInboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -93,7 +94,7 @@ def create_sip_outbound_trunk( headers_to_attributes: headers_to_attributes ) ) - self.rpc( + rpc!( :CreateSIPOutboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -102,7 +103,7 @@ def create_sip_outbound_trunk( def list_sip_inbound_trunk request = Proto::ListSIPInboundTrunkRequest.new - self.rpc( + rpc!( :ListSIPInboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -111,7 +112,7 @@ def list_sip_inbound_trunk def list_sip_outbound_trunk request = Proto::ListSIPOutboundTrunkRequest.new - self.rpc( + rpc!( :ListSIPOutboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -122,7 +123,7 @@ def delete_sip_trunk(sip_trunk_id) request = Proto::DeleteSIPTrunkRequest.new( sip_trunk_id: sip_trunk_id, ) - self.rpc( + rpc!( :DeleteSIPTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -149,7 +150,7 @@ def create_sip_dispatch_rule( attributes: attributes, room_config: room_config, ) - self.rpc( + rpc!( :CreateSIPDispatchRule, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -158,7 +159,7 @@ def create_sip_dispatch_rule( def list_sip_dispatch_rule request = Proto::ListSIPDispatchRuleRequest.new - self.rpc( + rpc!( :ListSIPDispatchRule, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -169,7 +170,7 @@ def delete_sip_dispatch_rule(sip_dispatch_rule_id) request = Proto::DeleteSIPDispatchRuleRequest.new( sip_dispatch_rule_id: sip_dispatch_rule_id, ) - self.rpc( + rpc!( :DeleteSIPDispatchRule, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -180,8 +181,13 @@ def create_sip_participant( sip_trunk_id, sip_call_to, room_name, + # Optional inline outbound trunk configuration (SIPOutboundConfig). Use + # instead of a stored sip_trunk_id to configure the trunk per call. + trunk: nil, # Optional SIP From number to use. If empty, trunk number is used. from_number: nil, + # Optional custom caller ID shown to the callee. Requires provider support. + display_name: nil, # Optional identity of the participant in LiveKit room participant_identity: nil, # Optional name of the participant in LiveKit room @@ -212,8 +218,10 @@ def create_sip_participant( ringing_timeout = DialTimeout::DEFAULT_RINGING_TIMEOUT if wait_until_answered && ringing_timeout.nil? request = Proto::CreateSIPParticipantRequest.new( sip_trunk_id: sip_trunk_id, + trunk: trunk, sip_call_to: sip_call_to, sip_number: from_number, + display_name: display_name, room_name: room_name, participant_identity: participant_identity, participant_name: participant_name, @@ -231,7 +239,7 @@ def create_sip_participant( # and the request must outlast ringing; otherwise honor any user timeout. effective_timeout = wait_until_answered ? DialTimeout.resolve(timeout, ringing_timeout) : timeout headers[Failover::TIMEOUT_HEADER] = effective_timeout.to_s if effective_timeout - self.rpc(:CreateSIPParticipant, request, headers: headers) + rpc!(:CreateSIPParticipant, request, headers: headers) end def transfer_sip_participant( @@ -258,7 +266,7 @@ def transfer_sip_participant( ) headers = auth_header(video_grant: VideoGrant.new(roomAdmin: true, room: room_name), sip_grant: SIPGrant.new(call: true)) headers[Failover::TIMEOUT_HEADER] = DialTimeout.resolve(timeout, ringing_timeout).to_s - self.rpc(:TransferSIPParticipant, request, headers: headers) + rpc!(:TransferSIPParticipant, request, headers: headers) end # Updates an existing SIP inbound trunk, replacing it entirely. @@ -270,7 +278,7 @@ def update_sip_inbound_trunk(sip_trunk_id, trunk) sip_trunk_id: sip_trunk_id, replace: trunk, ) - self.rpc( + rpc!( :UpdateSIPInboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -287,7 +295,7 @@ def update_sip_inbound_trunk_fields(sip_trunk_id, update) sip_trunk_id: sip_trunk_id, update: update, ) - self.rpc( + rpc!( :UpdateSIPInboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -303,7 +311,7 @@ def update_sip_outbound_trunk(sip_trunk_id, trunk) sip_trunk_id: sip_trunk_id, replace: trunk, ) - self.rpc( + rpc!( :UpdateSIPOutboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -320,7 +328,7 @@ def update_sip_outbound_trunk_fields(sip_trunk_id, update) sip_trunk_id: sip_trunk_id, update: update, ) - self.rpc( + rpc!( :UpdateSIPOutboundTrunk, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -336,7 +344,7 @@ def update_sip_dispatch_rule(sip_dispatch_rule_id, rule) sip_dispatch_rule_id: sip_dispatch_rule_id, replace: rule, ) - self.rpc( + rpc!( :UpdateSIPDispatchRule, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), @@ -353,7 +361,7 @@ def update_sip_dispatch_rule_fields(sip_dispatch_rule_id, update) sip_dispatch_rule_id: sip_dispatch_rule_id, update: update, ) - self.rpc( + rpc!( :UpdateSIPDispatchRule, request, headers: auth_header(sip_grant: SIPGrant.new(admin: true)), diff --git a/lib/livekit/version.rb b/lib/livekit/version.rb index 5e37e89..99f3d49 100644 --- a/lib/livekit/version.rb +++ b/lib/livekit/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module LiveKit - VERSION = "0.9.0" + VERSION = "1.0.0" end diff --git a/livekit_server_sdk.gemspec b/livekit_server_sdk.gemspec index f719085..2c72331 100644 --- a/livekit_server_sdk.gemspec +++ b/livekit_server_sdk.gemspec @@ -14,9 +14,9 @@ Gem::Specification.new do |spec| # google-protobuf 4.x requires Ruby >= 3.1; Ruby 3.0 is EOL. spec.required_ruby_version = ">= 3.1.0" - # spec.metadata["homepage_uri"] = spec.homepage - # spec.metadata["source_code_uri"] = "TODO: Put your gem's public repo URL here." - # spec.metadata["changelog_uri"] = "TODO: Put your gem's CHANGELOG.md URL here." + spec.metadata["homepage_uri"] = spec.homepage + spec.metadata["source_code_uri"] = "https://github.com/livekit/server-sdk-ruby" + spec.metadata["changelog_uri"] = "https://github.com/livekit/server-sdk-ruby/blob/main/CHANGELOG.md" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -27,6 +27,7 @@ Gem::Specification.new do |spec| spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } spec.require_paths = ["lib"] + spec.add_dependency "faraday", ">= 2.0", "< 3.0" spec.add_dependency "google-protobuf", "~> 4.30", ">= 4.30.2" spec.add_dependency "jwt", ">= 2.2.3", "< 3.0" spec.add_dependency "twirp", "~> 1.13", ">= 1.13.1" diff --git a/spec/api/livekit_api_spec.rb b/spec/api/livekit_api_spec.rb new file mode 100644 index 0000000..d8dc8f7 --- /dev/null +++ b/spec/api/livekit_api_spec.rb @@ -0,0 +1,291 @@ +# frozen_string_literal: true + +require 'json' +require 'faraday' +require 'livekit' + +# API tests that drive the unified LiveKitAPI against the shared mock LiveKit +# server (livekit/livekit cmd/test-server). Point them at a running instance +# with LK_TEST_SERVER_URL (default http://127.0.0.1:9999); they skip when no +# server is reachable. +# +# Because the mock enforces the same per-method grants as the real server, a +# call that succeeds also proves the SDK attached the right grants. The smoke +# examples fully populate each request so they double as a reference for a +# complete call. Mock directives are injected as a default X-Lk-Mock header on a +# service's connection (the public methods don't expose per-call headers). +PB = LiveKit::Proto + +RSpec.describe LiveKit::LiveKitAPI do + base = ENV.fetch('LK_TEST_SERVER_URL', 'http://127.0.0.1:9999') + server_up = begin + Faraday.get("#{base}/settings/regions").status == 200 + rescue StandardError + false + end + + def new_api + LiveKit::LiveKitAPI.new( + ENV.fetch('LK_TEST_SERVER_URL', 'http://127.0.0.1:9999'), + api_key: 'devkey', api_secret: 'secret' + ) + end + + # Attach X-Lk-Mock directives to a service client's connection. + def mock!(client, directives) + client.instance_variable_get(:@conn).headers['X-Lk-Mock'] = JSON.generate(directives) + client + end + + unless server_up + it 'is skipped because the mock test server is not reachable' do + skip "mock test server not reachable at #{base}" + end + next + end + + # -- smoke: fully-populated calls across every service ---------------------- + + it 'room service smoke' do + api = new_api + expect do + api.room.create_room('test-room', empty_timeout: 300, departure_timeout: 60, + max_participants: 50, metadata: '{"scene":"lobby"}', + min_playout_delay: 100, max_playout_delay: 2000, sync_streams: true) + api.room.list_rooms(names: ['test-room', 'lobby']) + api.room.delete_room(room: 'test-room') + api.room.list_participants(room: 'test-room') + api.room.get_participant(room: 'test-room', identity: 'participant-42') + api.room.remove_participant(room: 'test-room', identity: 'participant-42') + api.room.forward_participant(room: 'test-room', identity: 'participant-42', + destination_room: 'overflow-room') + api.room.move_participant(room: 'test-room', identity: 'participant-42', + destination_room: 'breakout-room') + api.room.mute_published_track(room: 'test-room', identity: 'participant-42', + track_sid: 'TR_video1', muted: true) + api.room.update_participant(room: 'test-room', identity: 'participant-42', name: 'Alice', + metadata: '{"role":"host"}', attributes: { 'seat' => '1A' }, + permission: PB::ParticipantPermission.new( + can_subscribe: true, can_publish: true, can_publish_data: true, + can_publish_sources: [PB::TrackSource::MICROPHONE, PB::TrackSource::CAMERA], + can_update_metadata: true + )) + api.room.update_subscriptions(room: 'test-room', identity: 'participant-42', + track_sids: ['TR_video1'], subscribe: true) + api.room.update_room_metadata(room: 'test-room', metadata: '{"scene":"intro"}') + api.room.send_data(room: 'test-room', data: 'hello world', kind: PB::DataPacket::Kind::RELIABLE, + destination_identities: ['participant-42']) + end.not_to raise_error + end + + it 'egress service smoke' do + api = new_api + mp4 = -> { PB::EncodedFileOutput.new(file_type: PB::EncodedFileType::MP4, filepath: 'out.mp4') } + stream = PB::StreamOutput.new(protocol: PB::StreamProtocol::RTMP, urls: ['rtmps://a.example.com/live/key']) + expect do + api.egress.start_room_composite_egress('test-room', mp4.call, layout: 'grid') + api.egress.start_web_egress('https://example.com/scene', stream) + api.egress.start_participant_egress('test-room', 'participant-42', mp4.call, screen_share: true) + api.egress.start_track_composite_egress('test-room', mp4.call, + audio_track_id: 'TR_audio1', video_track_id: 'TR_video1') + api.egress.start_track_egress('test-room', PB::DirectFileOutput.new(filepath: 'track.mp4'), 'TR_video1') + api.egress.update_layout('EG_abc123', 'speaker') + api.egress.update_stream('EG_abc123', add_output_urls: ['rtmps://b.example.com/live/key'], + remove_output_urls: ['rtmps://a.example.com/live/key']) + api.egress.list_egress(room_name: 'test-room', egress_id: 'EG_abc123', active: true) + api.egress.stop_egress('EG_abc123') + end.not_to raise_error + end + + it 'ingress service smoke' do + api = new_api + expect do + api.ingress.create_ingress( + PB::IngressInput::RTMP_INPUT, name: 'stream-input', room_name: 'test-room', + participant_identity: 'ingress-bot', participant_name: 'Live Stream', enable_transcoding: true, + audio: PB::IngressAudioOptions.new(name: 'audio', source: PB::TrackSource::MICROPHONE, + preset: PB::IngressAudioEncodingPreset::OPUS_STEREO_96KBPS), + video: PB::IngressVideoOptions.new(name: 'video', source: PB::TrackSource::CAMERA, + preset: PB::IngressVideoEncodingPreset::H264_1080P_30FPS_3_LAYERS) + ) + api.ingress.update_ingress('IN_abc123', name: 'stream-input-v2', room_name: 'test-room', + participant_identity: 'ingress-bot', enable_transcoding: true) + api.ingress.list_ingress(room_name: 'test-room', ingress_id: 'IN_abc123') + api.ingress.delete_ingress('IN_abc123') + end.not_to raise_error + end + + it 'sip service smoke' do + api = new_api + expect do + api.sip.create_sip_inbound_trunk('inbound', ['+15105550100'], metadata: '{"provider":"telco"}', + allowed_addresses: ['203.0.113.0/24'], allowed_numbers: ['+15105550111'], + auth_username: 'sip-user', auth_password: 'sip-pass', krisp_enabled: true) + api.sip.create_sip_outbound_trunk('outbound', 'sip.telco.example.com', ['+15105550100'], + transport: PB::SIPTransport::SIP_TRANSPORT_TLS, + auth_username: 'sip-user', auth_password: 'sip-pass') + api.sip.update_sip_inbound_trunk('ST_abc123', + PB::SIPInboundTrunkInfo.new(name: 'inbound-v2', numbers: ['+15105550100'])) + api.sip.update_sip_outbound_trunk('ST_abc123', + PB::SIPOutboundTrunkInfo.new(name: 'outbound-v2', address: 'sip.telco.example.com', + transport: PB::SIPTransport::SIP_TRANSPORT_TLS, + numbers: ['+15105550100'])) + api.sip.list_sip_inbound_trunk + api.sip.list_sip_outbound_trunk + api.sip.delete_sip_trunk('ST_abc123') + api.sip.create_sip_dispatch_rule( + PB::SIPDispatchRule.new(dispatch_rule_direct: PB::SIPDispatchRuleDirect.new(room_name: 'support', pin: '1234')), + name: 'direct-to-support', trunk_ids: ['ST_abc123'], metadata: '{"team":"support"}' + ) + api.sip.update_sip_dispatch_rule('SDR_abc123', + PB::SIPDispatchRuleInfo.new(name: 'individual-v2', + rule: PB::SIPDispatchRule.new( + dispatch_rule_individual: PB::SIPDispatchRuleIndividual.new(room_prefix: 'call-') + ))) + api.sip.list_sip_dispatch_rule + api.sip.delete_sip_dispatch_rule('SDR_abc123') + end.not_to raise_error + end + + it 'connector service smoke' do + api = new_api + offer = PB::SessionDescription.new(type: 'offer', sdp: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n") + answer = PB::SessionDescription.new(type: 'answer', sdp: "v=0\r\no=- 0 0 IN IP4 127.0.0.1\r\n") + expect do + api.connector.dial_whatsapp_call(PB::DialWhatsAppCallRequest.new( + whatsapp_phone_number_id: '123456789012345', whatsapp_to_phone_number: '+15105550100', + whatsapp_api_key: 'wa-secret-key', whatsapp_cloud_api_version: '23.0', + room_name: 'test-room', participant_identity: 'whatsapp-caller', destination_country: 'US' + )) + api.connector.accept_whatsapp_call(PB::AcceptWhatsAppCallRequest.new( + whatsapp_phone_number_id: '123456789012345', whatsapp_api_key: 'wa-secret-key', + whatsapp_cloud_api_version: '23.0', whatsapp_call_id: 'wacid.HBgLABC', sdp: answer, + room_name: 'test-room', participant_identity: 'whatsapp-callee' + )) + api.connector.connect_whatsapp_call(PB::ConnectWhatsAppCallRequest.new(whatsapp_call_id: 'wacid.HBgLABC', sdp: offer)) + api.connector.disconnect_whatsapp_call(PB::DisconnectWhatsAppCallRequest.new( + whatsapp_call_id: 'wacid.HBgLABC', whatsapp_api_key: 'wa-secret-key', + disconnect_reason: PB::DisconnectWhatsAppCallRequest::DisconnectReason::BUSINESS_INITIATED + )) + api.connector.connect_twilio_call(PB::ConnectTwilioCallRequest.new( + twilio_call_direction: PB::ConnectTwilioCallRequest::TwilioCallDirection::TWILIO_CALL_DIRECTION_INBOUND, + room_name: 'test-room', participant_identity: 'twilio-caller', destination_country: 'US' + )) + end.not_to raise_error + end + + it 'agent dispatch service smoke' do + api = new_api + expect do + api.agent_dispatch.create_dispatch('test-room', 'inbound-agent', metadata: '{"lang":"en"}') + api.agent_dispatch.get_dispatch('AD_abc123', 'test-room') + api.agent_dispatch.list_dispatch('test-room') + api.agent_dispatch.delete_dispatch('AD_abc123', 'test-room') + end.not_to raise_error + end + + # -- deep: create_room round-trip + error propagation ----------------------- + + it 'create_room echoes request fields' do + room = new_api.room.create_room('echo-room', metadata: '{"scene":"lobby"}', + empty_timeout: 300, max_participants: 50) + expect(room.name).to eq('echo-room') + expect(room.metadata).to eq('{"scene":"lobby"}') + expect(room.empty_timeout).to eq(300) + expect(room.max_participants).to eq(50) + expect(room.sid).not_to be_empty # placeholder assigned by the mock + end + + it 'raises ServerError on a server error' do + api = new_api + mock!(api.room, { 'failRegions' => [0], 'failStatus' => 400, 'failTwirpCode' => 'invalid_argument' }) + expect { api.room.create_room('test-room') }.to raise_error(LiveKit::ServerError) do |e| + expect(e.code).to eq('invalid_argument') + end + end + + # -- deep: SIP participant (delayMs:0 skips the mock's answer wait) ---------- + + it 'creates and transfers SIP participants' do + api = new_api + p = api.sip.create_sip_participant('ST_abc123', '+15105550100', 'test-room', + participant_identity: 'sip-caller', participant_name: 'SIP Caller', + participant_metadata: '{"source":"pstn"}', dtmf: '1234#', + play_dialtone: true, max_call_duration: 3600) + expect(p.room_name).to eq('test-room') + expect(p.participant_identity).to eq('sip-caller') + + # Inline outbound trunk config (no stored trunk id) + custom caller ID. + inline = api.sip.create_sip_participant('', '+15105550100', 'test-room', + trunk: LiveKit::Proto::SIPOutboundConfig.new( + hostname: 'sip.telco.example.com', + transport: :SIP_TRANSPORT_UDP + ), + from_number: '+15105550199', display_name: 'Support', + participant_identity: 'sip-inline') + expect(inline.room_name).to eq('test-room') + + mock!(api.sip, { 'delayMs' => 0 }) + expect do + api.sip.create_sip_participant('ST_abc123', '+15105550100', 'test-room', + wait_until_answered: true, ringing_timeout: 2) + api.sip.transfer_sip_participant('test-room', 'sip-caller', 'tel:+15105550122', ringing_timeout: 2) + end.not_to raise_error + end + + # -- cross-cutting: token auth ---------------------------------------------- + + it 'authenticates with a pre-signed token' do + token = LiveKit::AccessToken.new(api_key: 'devkey', api_secret: 'secret') + token.video_grant = LiveKit::VideoGrant.new(roomCreate: true) + api = LiveKit::LiveKitAPI.new(base, token: token.to_jwt) + room = api.room.create_room('token-room') + expect(room.name).to eq('token-room') + end + + # -- cross-cutting: SIP call errors raise SipCallError ---------------------- + + def sip_error(sip, wait: false) + api = new_api + mock!(api.sip, { 'delayMs' => 0, 'sipStatus' => sip }) + begin + api.sip.create_sip_participant('ST_abc123', '+15105550100', 'test-room', wait_until_answered: wait, + ringing_timeout: (wait ? 2 : nil)) + nil + rescue LiveKit::SipCallError => e + e + end + end + + it 'surfaces a busy signal as SipCallError' do + e = sip_error({ 'code' => 486, 'status' => 'Busy Here' }) + expect(e).to be_a(LiveKit::ServerError) + expect(e.code).to eq('resource_exhausted') + expect(e.sip_status_code).to eq(486) + expect(e.sip_status).to eq('Busy Here') + expect(e.to_s).to include('486').and include('Busy Here') + end + + it 'surfaces a carrier decline as SipCallError' do + e = sip_error({ 'code' => 603, 'status' => 'Decline' }) + expect(e.code).to eq('permission_denied') + expect(e.sip_status_code).to eq(603) + end + + it 'surfaces a no-answer timeout (SIP 408)' do + e = sip_error({ 'code' => 408, 'status' => 'Request Timeout' }, wait: true) + expect(e.code).to eq('deadline_exceeded') + expect(e.sip_status_code).to eq(408) + end + + # -- cross-cutting: client-side dial timeout -------------------------------- + + it 'times out when the answer exceeds the dial budget' do + api = new_api + mock!(api.sip, { 'delayMs' => 4000 }) # exceeds the ~3s dial budget (ringing 1s + margin) + expect do + api.sip.create_sip_participant('ST_abc123', '+15105550100', 'test-room', + wait_until_answered: true, ringing_timeout: 1) + end.to raise_error(Faraday::TimeoutError) + end +end diff --git a/spec/api/region_failover_spec.rb b/spec/api/region_failover_spec.rb index 838a432..2312b9e 100644 --- a/spec/api/region_failover_spec.rb +++ b/spec/api/region_failover_spec.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true +require 'json' require 'faraday' require 'livekit/failover' @@ -9,9 +10,9 @@ # is reachable. The mock returns Cache-Control: max-age=0, so the region cache # never stores entries and scenarios don't interfere. # -# See cmd/test-server/README.md for the X-Lk-Mock-* control protocol. These -# tests drive the middleware directly because the service client methods do not -# expose per-call headers. +# See cmd/test-server/README.md for the X-Lk-Mock JSON control protocol. These +# tests drive the middleware directly because failover relies on internal +# test-only knobs (force/backoff_base) the service clients don't expose. RSpec.describe LiveKit::RegionFailoverMiddleware do base = ENV.fetch('LK_TEST_SERVER_URL', 'http://127.0.0.1:9999') @@ -23,7 +24,7 @@ # +force+ bypasses the cloud-host check (the mock is on 127.0.0.1) and a tiny # backoff keeps the tests fast — both are internal, test-only knobs. - def call(base, directives, failover: true, force: true) + def call(base, mock = {}, failover: true, force: true) conn = Faraday.new(url: "#{base}/twirp") do |f| f.use LiveKit::RegionFailoverMiddleware, failover: failover, force: force, backoff_base: 0.001 f.adapter Faraday.default_adapter @@ -32,59 +33,58 @@ def call(base, directives, failover: true, force: true) req.headers['Authorization'] = 'Bearer test-token' req.headers['Content-Type'] = 'application/protobuf' # These tests exercise failover, not authz; skip the mock's permission check. - req.headers['X-Lk-Mock-Skip-Auth'] = 'true' + req.headers['X-Lk-Mock'] = JSON.generate({ skipAuth: true }.merge(mock)) req.body = '' - directives.each { |k, v| req.headers[k] = v } end end if server_up it 'succeeds on the primary when healthy' do - resp = call(base, {}) + resp = call(base) expect(resp.status).to eq(200) expect(resp.headers['X-Lk-Mock-Region']).to eq('0') end it 'fails over to a healthy region when the primary is down' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0' }) + resp = call(base, { 'failRegions' => [0] }) expect(resp.status).to eq(200) expect(resp.headers['X-Lk-Mock-Region']).to eq('1') end it 'fails over to region 2 on the third attempt' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0,1' }) + resp = call(base, { 'failRegions' => [0, 1] }) expect(resp.status).to eq(200) expect(resp.headers['X-Lk-Mock-Region']).to eq('2') end it 'surfaces an error when all regions are down' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0,1,2,3' }) + resp = call(base, { 'failRegions' => [0, 1, 2, 3] }) expect(resp.status).to eq(503) end it 'does not retry a 4xx' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0', 'X-Lk-Mock-Fail-Status' => '400' }) + resp = call(base, { 'failRegions' => [0], 'failStatus' => 400 }) expect(resp.status).to eq(400) end it 'fails over on a transport error' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0', 'X-Lk-Mock-Fail-Mode' => 'drop' }) + resp = call(base, { 'failRegions' => [0], 'failMode' => 'drop' }) expect(resp.status).to eq(200) expect(resp.headers['X-Lk-Mock-Region']).to eq('1') end it 'surfaces the original error when region discovery is unreachable' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0', 'X-Lk-Mock-Regions-Status' => '500' }) + resp = call(base, { 'failRegions' => [0], 'regionsStatus' => 500 }) expect(resp.status).to eq(503) end it 'does not fail over for a non-cloud host (cloud-gated)' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0' }, force: false) + resp = call(base, { 'failRegions' => [0] }, force: false) expect(resp.status).to eq(503) end it 'does not fail over when disabled' do - resp = call(base, { 'X-Lk-Mock-Fail-Regions' => '0' }, failover: false) + resp = call(base, { 'failRegions' => [0] }, failover: false) expect(resp.status).to eq(503) end else diff --git a/spec/livekit/agent_dispatch_service_client_spec.rb b/spec/livekit/agent_dispatch_service_client_spec.rb index 34f3dce..ff1fdbc 100644 --- a/spec/livekit/agent_dispatch_service_client_spec.rb +++ b/spec/livekit/agent_dispatch_service_client_spec.rb @@ -18,7 +18,7 @@ ).and_call_original # Mock the RPC call to avoid actual network request - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::AgentDispatch.new(id: dispatch_id) ) @@ -33,7 +33,7 @@ ).and_call_original # Mock the RPC call to avoid actual network request - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::AgentDispatch.new(id: dispatch_id) ) @@ -48,7 +48,7 @@ ).and_call_original # Mock the RPC call to avoid actual network request - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::ListAgentDispatchResponse.new( agent_dispatches: [LiveKit::Proto::AgentDispatch.new(id: dispatch_id)] ) @@ -65,7 +65,7 @@ ).and_call_original # Mock the RPC call to avoid actual network request - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::ListAgentDispatchResponse.new( agent_dispatches: [] ) @@ -78,14 +78,14 @@ describe 'Ruby 3 compatibility' do it 'does not raise ArgumentError when calling methods' do # Mock the RPC calls - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::AgentDispatch.new(id: dispatch_id) ) expect { client.create_dispatch(room_name, agent_name) }.not_to raise_error expect { client.delete_dispatch(dispatch_id, room_name) }.not_to raise_error - allow(client).to receive(:rpc).and_return( + allow(client).to receive(:rpc!).and_return( LiveKit::Proto::ListAgentDispatchResponse.new(agent_dispatches: []) )