teams_rb 2.0.1

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.
Files changed (86) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE +21 -0
  4. data/README.md +500 -0
  5. data/docs/README.md +40 -0
  6. data/docs/essentials/README.md +13 -0
  7. data/docs/essentials/api-client.md +62 -0
  8. data/docs/essentials/app-authentication.md +27 -0
  9. data/docs/essentials/app-basics.md +50 -0
  10. data/docs/essentials/graph.md +41 -0
  11. data/docs/essentials/on-activity.md +72 -0
  12. data/docs/essentials/on-event.md +29 -0
  13. data/docs/essentials/proactive-messaging.md +44 -0
  14. data/docs/essentials/sending-messages.md +76 -0
  15. data/docs/essentials/sovereign-cloud.md +26 -0
  16. data/docs/getting-started/README.md +7 -0
  17. data/docs/getting-started/code-basics.md +61 -0
  18. data/docs/getting-started/quickstart.md +54 -0
  19. data/docs/getting-started/running-in-teams.md +44 -0
  20. data/docs/in-depth-guides/README.md +14 -0
  21. data/docs/in-depth-guides/adaptive-cards.md +62 -0
  22. data/docs/in-depth-guides/dialogs.md +77 -0
  23. data/docs/in-depth-guides/feedback.md +29 -0
  24. data/docs/in-depth-guides/meeting-events.md +27 -0
  25. data/docs/in-depth-guides/message-extensions.md +77 -0
  26. data/docs/in-depth-guides/message-reactions.md +29 -0
  27. data/docs/in-depth-guides/observability.md +28 -0
  28. data/docs/in-depth-guides/streaming.md +52 -0
  29. data/docs/in-depth-guides/tabs.md +59 -0
  30. data/docs/in-depth-guides/user-authentication.md +60 -0
  31. data/lib/teams/activity.rb +160 -0
  32. data/lib/teams/activity_context.rb +278 -0
  33. data/lib/teams/api/account.rb +90 -0
  34. data/lib/teams/api/activity_value.rb +49 -0
  35. data/lib/teams/api/bot_sign_in_client.rb +54 -0
  36. data/lib/teams/api/channel_data.rb +86 -0
  37. data/lib/teams/api/channel_info.rb +19 -0
  38. data/lib/teams/api/citation_appearance.rb +83 -0
  39. data/lib/teams/api/client.rb +28 -0
  40. data/lib/teams/api/conversation_account.rb +40 -0
  41. data/lib/teams/api/conversation_client.rb +156 -0
  42. data/lib/teams/api/conversation_reference.rb +83 -0
  43. data/lib/teams/api/conversation_resource.rb +19 -0
  44. data/lib/teams/api/meeting_client.rb +57 -0
  45. data/lib/teams/api/meeting_info.rb +26 -0
  46. data/lib/teams/api/meeting_notification_response.rb +29 -0
  47. data/lib/teams/api/meeting_participant.rb +33 -0
  48. data/lib/teams/api/message_activity.rb +201 -0
  49. data/lib/teams/api/message_extension.rb +110 -0
  50. data/lib/teams/api/model.rb +49 -0
  51. data/lib/teams/api/notification_info.rb +28 -0
  52. data/lib/teams/api/paged_members_result.rb +16 -0
  53. data/lib/teams/api/quoted_reply_entity.rb +65 -0
  54. data/lib/teams/api/reaction_client.rb +34 -0
  55. data/lib/teams/api/sent_activity.rb +48 -0
  56. data/lib/teams/api/task_module.rb +83 -0
  57. data/lib/teams/api/team_client.rb +45 -0
  58. data/lib/teams/api/team_details.rb +36 -0
  59. data/lib/teams/api/team_info.rb +44 -0
  60. data/lib/teams/api/tenant_info.rb +11 -0
  61. data/lib/teams/api/token.rb +81 -0
  62. data/lib/teams/api/typing_activity.rb +19 -0
  63. data/lib/teams/api/user_client.rb +83 -0
  64. data/lib/teams/app.rb +602 -0
  65. data/lib/teams/auth/client_secret_credentials.rb +7 -0
  66. data/lib/teams/auth/jwt_validator.rb +148 -0
  67. data/lib/teams/auth/token.rb +39 -0
  68. data/lib/teams/auth/token_manager.rb +68 -0
  69. data/lib/teams/cards/generated.rb +1764 -0
  70. data/lib/teams/cards/generated_base.rb +105 -0
  71. data/lib/teams/cards.rb +81 -0
  72. data/lib/teams/cloud_environment.rb +41 -0
  73. data/lib/teams/common/hashes.rb +20 -0
  74. data/lib/teams/common/http_client.rb +111 -0
  75. data/lib/teams/common/retry.rb +31 -0
  76. data/lib/teams/errors.rb +50 -0
  77. data/lib/teams/function_context.rb +82 -0
  78. data/lib/teams/graph/client.rb +73 -0
  79. data/lib/teams/http_stream.rb +460 -0
  80. data/lib/teams/rack_app.rb +62 -0
  81. data/lib/teams/response.rb +9 -0
  82. data/lib/teams/router.rb +171 -0
  83. data/lib/teams/storage/memory_store.rb +27 -0
  84. data/lib/teams/version.rb +5 -0
  85. data/lib/teams.rb +70 -0
  86. metadata +217 -0
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Teams
6
+ module Cards
7
+ # Base class for the generated Adaptive Card models. Serialization matches
8
+ # the other Teams SDKs' generated card classes: non-nil fields (including
9
+ # defaults) are emitted under their camelCase aliases, unknown extra
10
+ # fields pass through verbatim.
11
+ class GeneratedCard
12
+ OMIT = Object.new.freeze
13
+
14
+ class << self
15
+ def card_fields
16
+ @card_fields ||= superclass.respond_to?(:card_fields) ? superclass.card_fields.dup : {}
17
+ end
18
+
19
+ def field(name, alias_name, default = nil, mutable: false)
20
+ card_fields[name] = { alias: alias_name, default:, mutable: }
21
+
22
+ define_method(name) { @values[name] }
23
+ define_method("#{name}=") { |value| @values[name] = value }
24
+ define_method("with_#{name}") do |value|
25
+ @values[name] = value
26
+ self
27
+ end
28
+ end
29
+
30
+ # Ruby convenience preserved from the original hand-written classes:
31
+ # allows the first constructor argument to set the given field.
32
+ def positional_field(name)
33
+ define_method(:initialize) do |positional = OMIT, **values|
34
+ values[name] = positional unless positional.equal?(OMIT)
35
+ super(**values)
36
+ end
37
+ end
38
+
39
+ # Same, for classes whose hand-written predecessors took a splat.
40
+ def splat_field(name)
41
+ define_method(:initialize) do |*positional, **values|
42
+ values[name] = positional.flatten unless positional.empty?
43
+ super(**values)
44
+ end
45
+ end
46
+
47
+ def array_adder(method_name, field_name)
48
+ define_method(method_name) do |item|
49
+ (@values[field_name] ||= []) << item
50
+ self
51
+ end
52
+ end
53
+ end
54
+
55
+ def initialize(**values)
56
+ @values = {}
57
+ self.class.card_fields.each do |name, spec|
58
+ @values[name] = if values.key?(name)
59
+ values.delete(name)
60
+ elsif spec[:mutable]
61
+ deep_dup(spec[:default])
62
+ else
63
+ spec[:default]
64
+ end
65
+ end
66
+ values.each { |key, value| @values[key] = value }
67
+ end
68
+
69
+ def to_h
70
+ @values.each_with_object({}) do |(name, value), body|
71
+ next if value.nil?
72
+
73
+ spec = self.class.card_fields[name]
74
+ body[spec ? spec[:alias] : camelize(name.to_s)] = serialize(value)
75
+ end
76
+ end
77
+
78
+ def to_json(*args)
79
+ JSON.generate(to_h, *args)
80
+ end
81
+
82
+ private
83
+
84
+ def serialize(value)
85
+ case value
86
+ when Array
87
+ value.map { |item| serialize(item) }
88
+ when Hash
89
+ value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = serialize(item) }
90
+ else
91
+ value.respond_to?(:to_h) && !value.is_a?(Numeric) ? value.to_h : value
92
+ end
93
+ end
94
+
95
+ def deep_dup(value)
96
+ Marshal.load(Marshal.dump(value))
97
+ end
98
+
99
+ def camelize(key)
100
+ parts = key.split("_")
101
+ parts.first + parts.drop(1).map(&:capitalize).join
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cards/generated_base"
5
+
6
+ module Teams
7
+ module Cards
8
+ class CardObject
9
+ attr_reader :options
10
+
11
+ def initialize(**options)
12
+ @options = options
13
+ end
14
+
15
+ def to_h
16
+ compact_hash(serialize_options(options))
17
+ end
18
+
19
+ def to_json(*args)
20
+ JSON.generate(to_h, *args)
21
+ end
22
+
23
+ def with_options(options = nil, **kwargs)
24
+ options = (options || {}).merge(kwargs)
25
+ options.each { |key, value| with_option(key, value) }
26
+ self
27
+ end
28
+
29
+ private
30
+
31
+ def with_option(key, value)
32
+ options[key] = value
33
+ self
34
+ end
35
+
36
+ def serialize(value)
37
+ case value
38
+ when nil
39
+ nil
40
+ when Array
41
+ value.map { |item| serialize(item) }
42
+ when Hash
43
+ serialize_options(value)
44
+ else
45
+ value.respond_to?(:to_h) ? value.to_h : value
46
+ end
47
+ end
48
+
49
+ def serialize_options(values)
50
+ values.each_with_object({}) do |(key, value), hash|
51
+ hash[json_key(key)] = serialize(value)
52
+ end
53
+ end
54
+
55
+ def compact_hash(hash)
56
+ hash.reject { |_key, value| value.nil? }
57
+ end
58
+
59
+ def json_key(key)
60
+ return key if key.is_a?(String)
61
+
62
+ case key
63
+ when :schema
64
+ "$schema"
65
+ when :choices_data
66
+ "choices.data"
67
+ when :grid_area
68
+ "grid.area"
69
+ else
70
+ camelize(key)
71
+ end
72
+ end
73
+
74
+ def camelize(key)
75
+ parts = key.to_s.split("_")
76
+ parts.first + parts.drop(1).map(&:capitalize).join
77
+ end
78
+ end
79
+
80
+ end
81
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ CloudEnvironment = Struct.new(
5
+ :login_endpoint,
6
+ :login_tenant,
7
+ :bot_scope,
8
+ :token_service_url,
9
+ :open_id_metadata_url,
10
+ :token_issuer,
11
+ :graph_scope,
12
+ keyword_init: true
13
+ )
14
+
15
+ PUBLIC_CLOUD = CloudEnvironment.new(
16
+ login_endpoint: "https://login.microsoftonline.com",
17
+ login_tenant: "botframework.com",
18
+ bot_scope: "https://api.botframework.com/.default",
19
+ token_service_url: "https://token.botframework.com",
20
+ open_id_metadata_url: "https://login.botframework.com/v1/.well-known/openidconfiguration",
21
+ token_issuer: "https://api.botframework.com",
22
+ graph_scope: "https://graph.microsoft.com/.default"
23
+ )
24
+
25
+ module CloudEnvironments
26
+ module_function
27
+
28
+ def public
29
+ PUBLIC_CLOUD
30
+ end
31
+
32
+ def from_name(name)
33
+ case name.to_s.downcase
34
+ when "", "public"
35
+ public
36
+ else
37
+ raise ConfigurationError, "unsupported cloud environment: #{name.inspect}"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Common
5
+ module Hashes
6
+ module_function
7
+
8
+ def deep_stringify_keys(value)
9
+ case value
10
+ when Hash
11
+ value.each_with_object({}) { |(key, item), hash| hash[key.to_s] = deep_stringify_keys(item) }
12
+ when Array
13
+ value.map { |item| deep_stringify_keys(item) }
14
+ else
15
+ value
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Teams
7
+ module Common
8
+ class HttpClient
9
+ attr_reader :base_url, :headers, :token
10
+
11
+ def initialize(base_url: nil, headers: {}, token: nil, connection: nil)
12
+ @base_url = base_url
13
+ @headers = headers
14
+ @token = token
15
+ @connection = connection
16
+ end
17
+
18
+ def get(path, headers: {}, params: nil)
19
+ request(:get, path, headers:, params:)
20
+ end
21
+
22
+ def post(path, json: nil, body: nil, headers: {}, params: nil)
23
+ request(:post, path, json:, body:, headers:, params:)
24
+ end
25
+
26
+ def patch(path, json: nil, body: nil, headers: {}, params: nil)
27
+ request(:patch, path, json:, body:, headers:, params:)
28
+ end
29
+
30
+ def put(path, json: nil, body: nil, headers: {}, params: nil)
31
+ request(:put, path, json:, body:, headers:, params:)
32
+ end
33
+
34
+ def delete(path, headers: {}, params: nil)
35
+ request(:delete, path, headers:, params:)
36
+ end
37
+
38
+ def request(method, path, json: nil, body: nil, headers: {}, params: nil)
39
+ resolved_token = resolve_token
40
+ response = connection.public_send(method) do |request|
41
+ request.url(path)
42
+ request.params.update(params) if params
43
+ request.headers.update(default_headers)
44
+ request.headers.update(headers)
45
+ request.headers["Authorization"] = "Bearer #{resolved_token}" if resolved_token
46
+
47
+ if json
48
+ request.headers["Content-Type"] ||= "application/json"
49
+ request.body = JSON.generate(json)
50
+ elsif body
51
+ request.body = body
52
+ end
53
+ end
54
+
55
+ parse_response(response)
56
+ end
57
+
58
+ def clone(base_url: nil, headers: nil, token: nil)
59
+ self.class.new(
60
+ base_url: base_url || self.base_url,
61
+ headers: self.headers.merge(headers || {}),
62
+ token: token || self.token,
63
+ connection: @connection
64
+ )
65
+ end
66
+
67
+ private
68
+
69
+ def connection
70
+ @connection ||= Faraday.new(url: base_url) do |faraday|
71
+ # Flat params: arrays encode as repeated plain keys (a=1&a=2),
72
+ # the shape the Bot Framework token service expects.
73
+ faraday.options.params_encoder = Faraday::FlatParamsEncoder
74
+ faraday.adapter Faraday.default_adapter
75
+ end
76
+ end
77
+
78
+ def default_headers
79
+ { "User-Agent" => "teams_rb/#{Teams::VERSION}" }.merge(headers)
80
+ end
81
+
82
+ def resolve_token
83
+ @resolved_token = token.respond_to?(:call) ? token.call : token
84
+ end
85
+
86
+ def parse_response(response)
87
+ body = response.body.to_s
88
+ parsed = begin
89
+ body.empty? ? nil : JSON.parse(body)
90
+ rescue JSON::ParserError
91
+ body
92
+ end
93
+
94
+ if response.status < 200 || response.status >= 300
95
+ message = "HTTP request failed with status #{response.status}"
96
+ message = "#{message}: #{parsed}" if parsed && parsed != ""
97
+
98
+ raise HttpError.new(
99
+ message,
100
+ status: response.status,
101
+ headers: response.headers,
102
+ body: parsed || body,
103
+ request: response.env&.request
104
+ )
105
+ end
106
+
107
+ parsed
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Common
5
+ module Retry
6
+ module_function
7
+
8
+ # Retries the block with exponential backoff, mirroring the retry
9
+ # helpers the TypeScript and Python SDKs use for stream sends.
10
+ # jitter :full randomizes each wait between 0 and the capped delay;
11
+ # :none waits the capped delay exactly.
12
+ def call(max_attempts: 5, delay: 0.5, max_delay: 30.0, jitter: :full, non_retryable: [], logger: nil)
13
+ attempt = 1
14
+ begin
15
+ yield
16
+ rescue *non_retryable
17
+ raise
18
+ rescue StandardError => error
19
+ raise if attempt >= max_attempts
20
+
21
+ capped = [delay * (2**(attempt - 1)), max_delay].min
22
+ wait = jitter == :full ? rand * capped : capped
23
+ logger&.debug("retrying in #{format('%.2f', wait)}s (attempt #{attempt}/#{max_attempts}): #{error.message}")
24
+ sleep(wait)
25
+ attempt += 1
26
+ retry
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ class Error < StandardError; end
5
+
6
+ class ConfigurationError < Error; end
7
+
8
+ class AuthenticationError < Error; end
9
+
10
+ class BadRequestError < Error; end
11
+
12
+ # Raised when Teams cancels a stream (for example, the user pressed Stop)
13
+ # or when a stream operation is attempted after cancellation.
14
+ class StreamCancelledError < Error; end
15
+
16
+ # Base class for terminal streaming errors (HTTP 403) that should not be retried.
17
+ # https://learn.microsoft.com/en-us/microsoftteams/platform/bots/streaming-ux?tabs=csharp#error-codes
18
+ class TerminalStreamError < Error; end
19
+
20
+ # Raised when the bot failed to complete streaming within the two-minute limit.
21
+ class StreamTimedOutError < TerminalStreamError; end
22
+
23
+ # Raised when streaming is not allowed for this user or bot.
24
+ class StreamNotAllowedError < TerminalStreamError; end
25
+
26
+ # A failed Microsoft Graph request; code carries the Graph error code
27
+ # (e.g. "Authorization_RequestDenied") and body the full error payload.
28
+ class GraphError < Error
29
+ attr_reader :status, :code, :body
30
+
31
+ def initialize(message, status:, code: nil, body: nil)
32
+ super(message)
33
+ @status = status
34
+ @code = code
35
+ @body = body
36
+ end
37
+ end
38
+
39
+ class HttpError < Error
40
+ attr_reader :status, :headers, :body, :request
41
+
42
+ def initialize(message, status:, headers:, body:, request: nil)
43
+ super(message)
44
+ @status = status
45
+ @headers = headers
46
+ @body = body
47
+ @request = request
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ # Context for a remote function call from a tab (POST /api/functions/{name}):
5
+ # the validated Entra identity, the Teams client context headers, and the
6
+ # request payload.
7
+ class FunctionContext
8
+ attr_reader :app, :function_name, :data, :app_session_id, :page_id, :auth_token,
9
+ :tenant_id, :user_id, :user_name, :app_id, :channel_id, :chat_id,
10
+ :meeting_id, :message_id, :sub_page_id, :team_id
11
+
12
+ def initialize(app:, function_name:, data:, app_session_id:, page_id:, auth_token:,
13
+ tenant_id:, user_id:, user_name:, app_id: nil, channel_id: nil,
14
+ chat_id: nil, meeting_id: nil, message_id: nil, sub_page_id: nil, team_id: nil)
15
+ @app = app
16
+ @function_name = function_name
17
+ @data = data
18
+ @app_session_id = app_session_id
19
+ @page_id = page_id
20
+ @auth_token = auth_token
21
+ @tenant_id = tenant_id
22
+ @user_id = user_id
23
+ @user_name = user_name
24
+ @app_id = app_id
25
+ @channel_id = channel_id
26
+ @chat_id = chat_id
27
+ @meeting_id = meeting_id
28
+ @message_id = message_id
29
+ @sub_page_id = sub_page_id
30
+ @team_id = team_id
31
+ @resolved = false
32
+ end
33
+
34
+ def api
35
+ app.api
36
+ end
37
+
38
+ def log
39
+ app.logger
40
+ end
41
+
42
+ # Resolves the conversation this call relates to, like the TypeScript and
43
+ # Python function contexts: chat/channel id when the user is a member of
44
+ # it; in personal scope, creates (or re-fetches) the 1:1 conversation.
45
+ # Returns nil when no conversation can be resolved.
46
+ def conversation_id
47
+ return @conversation_id if @resolved
48
+
49
+ @resolved = true
50
+ @conversation_id = resolve_conversation_id
51
+ end
52
+
53
+ # Posts into the resolved conversation (proactive send with the bot's
54
+ # identity).
55
+ def post(activity_or_text)
56
+ id = conversation_id
57
+ raise Error, "Unable to resolve a conversation for this function call" unless id
58
+
59
+ app.post(id, activity_or_text)
60
+ end
61
+
62
+ private
63
+
64
+ def resolve_conversation_id
65
+ existing = chat_id || channel_id
66
+
67
+ if existing.nil?
68
+ conversation = api.conversations.create(
69
+ members: [{ "id" => user_id, "role" => "user", "name" => user_name }],
70
+ tenant_id: tenant_id
71
+ )
72
+ return conversation.id
73
+ end
74
+
75
+ member = api.conversations.get_member_by_id(existing, user_id)
76
+ member ? existing : nil
77
+ rescue HttpError => error
78
+ log&.warn("Could not resolve conversation for function call: #{error.message}")
79
+ nil
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Teams
4
+ module Graph
5
+ # Thin Microsoft Graph HTTP client, following the TypeScript SDK's core
6
+ # Graph client (Python/.NET delegate to the official Graph SDKs, which
7
+ # Ruby lacks a maintained equivalent of; TypeScript's generated endpoint
8
+ # packages are out of scope, so the raw request surface is the API here,
9
+ # like TypeScript's client.http escape hatch).
10
+ class Client
11
+ DEFAULT_BASE_URL_ROOT = "https://graph.microsoft.com"
12
+
13
+ attr_reader :base_url_root, :version, :http
14
+
15
+ # token: a string or a callable returning one (an app-only Graph token
16
+ # or a user token from sign-in). base_url_root overrides the Graph host
17
+ # for sovereign clouds (e.g. "https://graph.microsoft.us").
18
+ def initialize(token:, base_url_root: nil, version: "v1.0", http: nil)
19
+ @base_url_root = (base_url_root || DEFAULT_BASE_URL_ROOT).sub(%r{/+\z}, "")
20
+ @version = version
21
+ @http = http || Common::HttpClient.new(token:)
22
+ end
23
+
24
+ def get(path, params: nil)
25
+ request { http.get(url(path), params:) }
26
+ end
27
+
28
+ def post(path, json: nil, params: nil)
29
+ request { http.post(url(path), json: stringify(json), params:) }
30
+ end
31
+
32
+ def patch(path, json: nil, params: nil)
33
+ request { http.patch(url(path), json: stringify(json), params:) }
34
+ end
35
+
36
+ def put(path, json: nil, params: nil)
37
+ request { http.put(url(path), json: stringify(json), params:) }
38
+ end
39
+
40
+ def delete(path, params: nil)
41
+ request { http.delete(url(path), params:) }
42
+ end
43
+
44
+ private
45
+
46
+ def request
47
+ yield
48
+ rescue HttpError => error
49
+ graph_error = error.body.is_a?(Hash) ? error.body["error"] : nil
50
+ message = if graph_error.is_a?(Hash) && graph_error["message"]
51
+ "Graph request failed (#{error.status}): #{graph_error["message"]}"
52
+ else
53
+ "Graph request failed with status #{error.status}"
54
+ end
55
+
56
+ raise GraphError.new(
57
+ message,
58
+ status: error.status,
59
+ code: graph_error.is_a?(Hash) ? graph_error["code"] : nil,
60
+ body: error.body
61
+ )
62
+ end
63
+
64
+ def url(path)
65
+ "#{base_url_root}/#{version}/#{path.to_s.sub(%r{\A/+}, "")}"
66
+ end
67
+
68
+ def stringify(json)
69
+ json.is_a?(Hash) ? Common::Hashes.deep_stringify_keys(json) : json
70
+ end
71
+ end
72
+ end
73
+ end