File: api.rb

package info (click to toggle)
mikutter 5.0.4%2Bdfsg1-2
  • links: PTS, VCS
  • area: main
  • in suites: bookworm
  • size: 9,700 kB
  • sloc: ruby: 21,307; sh: 181; makefile: 19
file content (330 lines) | stat: -rw-r--r-- 11,777 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
require 'httpclient'
require 'json'
require 'stringio'

module Plugin::Mastodon
  class APIResult
    attr_reader :value
    attr_reader :header

    def initialize(value, header = nil)
      @value = value
      @header = header
    end

    def [](idx)
      @value[idx]
    end

    def []=(idx, val)
      @value[idx] = val
    end

    def to_h(&block)
      @value.to_h(&block)
    end

    def to_a
      @value.to_a
    end
  end

  class API
    LINK_HEADER_MATCHER = %r<^<(.*)>; rel="(.*)"$>.freeze
    ExceptionResponse = Struct.new(:body) do
      def code
        0
      end
    end

    class << self
      def build_query_recurse(params, results = [], files = [], prefix = '', to_multipart = false)
        if params.is_a? Hash
          # key-value pairs
          params.each do |key, val|
            inner_prefix = "#{prefix}[#{key.to_s}]"
            results, files, to_multipart = build_query_recurse(val, results, files, inner_prefix, to_multipart)
          end
        elsif params.is_a? Array
          params.each_index do |i|
            inner_prefix = "#{prefix}[#{i}]"
            results, files, to_multipart = build_query_recurse(params[i], results, files, inner_prefix, to_multipart)
          end
        elsif params.is_a? Set
          results, files, to_multipart = build_query_recurse(params.to_a, results, files, prefix, to_multipart)
        else
          key = "#{prefix}".sub('[', '').sub(']', '')
          /^(.*)\[\d+\]$/.match(key) do |m|
            key = "#{m[1]}[]"
          end
          value = params
          if value.is_a?(Pathname) || value.is_a?(Plugin::Photo::Photo)
            to_multipart = true
          end

          case value
          when Pathname
            # multipart/form-data にするが、POSTリクエストではない可能性がある(PATCH等)ため、ある程度自力でつくる。
            # boundary作成や実際のbody構築はhttpclientに任せる。
            filename = value.basename.to_s
            disposition = "form-data; name=\"#{key}\"; filename=\"#{filename}\""
            f = File.open(value.to_s, 'rb')
            files << f
            results << {
              "Content-Type" => "application/octet-stream",
              "Content-Disposition" => disposition,
              :content => f,
            }
          when Plugin::Photo::Photo
            filename = Pathname(value.perma_link.path).basename.to_s
            disposition = "form-data; name=\"#{key}\"; filename=\"#{filename}\""
            results << {
              "Content-Type" => "application/octet-stream",
              "Content-Disposition" => "form-data; name=\"#{key}\"; filename=\"#{filename}\"",
              :content => StringIO.new(value.blob, 'r'),
            }
          else
            if to_multipart
              results << {
                "Content-Type" => "application/octet-stream",
                "Content-Disposition" => "form-data; name=\"#{key}\"",
                :content => value,
              }
            else
              results << [key, value]
            end
          end
        end
        [results, files, to_multipart]
      end

      # httpclient向けにパラメータHashを変換する
      def build_query(params, headers)
        results, files, to_multipart = build_query_recurse(params)
        if to_multipart
          headers << ["Content-Type", "multipart/form-data"]
        end
        [results, headers, files]
      end

      # 直接エンドポイントを指定してサーバにHTTPリクエストを行う。
      # ==== Args
      # [method] Symbol HTTP method. :get :post :put :patch :delete のいずれか。
      # [domain] String サーバのドメイン
      # [path] String エンドポイントのパス
      # [access_token] String アクセストークン。認証不要のエンドポイントへのアクセスなら省略するかnilを指定可能
      # [_opts] 使われていない。 TODO: 消す
      # [headers] Array 追加で送るHTTPヘッダ。[[name, value], ...] のような形式で渡す
      # [**params] URLのクエリ部分。
      # ==== Return
      # [Delayer::Deferred] HTTPレスポンスを受け取るDeferred
      # HTTPレスポンスが2xx → 成功。APIResponseを渡す
      # else                → 失敗。HTTPClient::Responseを渡す
      def call(method, domain, path = nil, access_token = nil, _opts = {}, headers = [], **params)
        promise = Delayer::Deferred.new(true)
        Thread.new do
          uri = domain_path_to_uri(domain, path)
          promise.call(raw_response!(method, uri, access_token, headers, **params))
        rescue => err
          promise.fail(err)
        end
        promise.next{ |response|
          case response.status
          when 200...300
            parse_link(response, JSON.parse(response.content, symbolize_names: true))
          else
            Delayer::Deferred.fail(response)
          end
        }
      end

      # TODO: callを使うようにしてdeprecateにする
      def call!(method, domain, path = nil, access_token = nil, opts = {}, headers = [], **params)
        uri = domain_path_to_uri(domain, path)
        resp = raw_response!(method, uri, access_token, headers, **params)
        case resp&.status
        when 200
          parse_link(resp, JSON.parse(resp.content, symbolize_names: true))
        end
      end

      def raw_response!(method, uri, access_token, headers, **params)
        if access_token && !access_token.empty?
          headers += [["Authorization", "Bearer " + access_token]]
        end
        query_timer(method, uri, params, headers) do
          send_request(method, params, headers, uri)
        end
      end

      private def domain_path_to_uri(domain, path)
        if domain.is_a? Diva::URI
          domain
        else
          Diva::URI.new('https://' + domain + path)
        end
      end

      private def query_timer(method, uri, params, headers, &block)
        start_time = Time.new.freeze
        serial = uri.to_s.hash ^ params.freeze.hash
        Plugin.call(:query_start,
                    serial:     serial,
                    method:     method,
                    path:       uri,
                    options:    params,
                    headers:    headers,
                    start_time: start_time)
        result = block.call
        Plugin.call(:query_end,
                    serial:     serial,
                    method:     method,
                    path:       uri,
                    options:    params,
                    start_time: start_time,
                    end_time:   Time.new.freeze,
                    res:        result)
        result
      rescue => exc
        Plugin.call(:query_end,
                    serial:     serial,
                    method:     method,
                    path:       uri,
                    options:    params,
                    start_time: start_time,
                    end_time:   Time.new.freeze,
                    res:        ExceptionResponse.new("#{exc.message}\n" + exc.backtrace.join("\n")))
        raise
      end

      private def send_request(method, params, headers, uri)
        query, headers, files = build_query(params, headers)
        body = nil
        if method != :get  # :post, :patch
          body = query
          query = nil
        end
        client = HTTPClient.new
        client.ssl_config.set_default_paths
        client.request(method, uri.to_s, query, body, headers)
      ensure
        files&.each(&:close)
      end

      def parse_link(resp, hash)
        link = resp.header['Link'].first
        return APIResult.new(hash) unless hash.is_a?(Array) && link
        APIResult.new(
          hash,
          link
            .split(', ')
            .map(&LINK_HEADER_MATCHER.method(:match))
            .compact
            .map { |matched| [matched[2].to_sym, Diva::URI.new(matched[1])] }
            .to_h
        )
      end

      def status(domain, id)
        call(:get, domain, '/api/v1/statuses/' + id.to_s)
      end

      def status!(domain, id)
        call!(:get, domain, '/api/v1/statuses/' + id.to_s)
      end

      def status_by_url(domain, access_token, url)
        call(:get, domain, '/api/v2/search', access_token, q: url.to_s, resolve: true).next{ |resp|
          resp[:statuses]
        }
      end

      def status_by_url!(domain, access_token, url)
        call!(:get, domain, '/api/v2/search', access_token, q: url.to_s, resolve: true).next{ |resp|
          resp[:statuses]
        }
      end

      def account_by_url(domain, access_token, url)
        call(:get, domain, '/api/v2/search', access_token, q: url.to_s, resolve: true).next{ |resp|
          resp[:accounts]
        }
      end

      # _world_ における、 _status_ のIDを検索して返す。
      # _status_ が _world_ の所属するのと同じサーバに投稿されたものなら、 _status_ のID。
      # 異なるサーバの _status_ なら、 _world_ のサーバに問い合わせて、そのIDを返すDeferredをリクエストする。
      # ==== Args
      # [world] World Model
      # [status] トゥート
      # ==== Return
      # [Delayer::Deferred] _status_ のローカルにおけるID
      def get_local_status_id(world, status)
        if world.domain == status.domain
          Delayer::Deferred.new{ status.id }
        else
          status_by_url(world.domain, world.access_token, status.url).next{ |statuses|
            statuses.dig(0, :id).tap do |id|
              raise 'Status id does not found.' unless id
            end
          }
        end
      end

      # _world_ における、 _account_ のIDを検索して返す。
      # _account_ が _world_ の所属するのと同じサーバに投稿されたものなら、 _account_ のID。
      # 異なるサーバの _account_ なら、 _world_ のサーバに問い合わせて、そのIDを返すDeferredをリクエストする。
      # ==== Args
      # [world] World Model
      # [account] アカウント (Plugin::Mastodon::Account)
      # ==== Return
      # [Delayer::Deferred] _account_ のローカルにおけるID
      def get_local_account_id(world, account)
        if world.domain == account.domain
          Delayer::Deferred.new{ account.id }
        else
          account_by_url(world.domain, world.access_token, account.url).next{ |accounts|
            accounts&.dig(0, :id)
          }
        end
      end

      # Link headerがあるAPIを連続的に叩いて1要素ずつyieldする
      # ==== Args
      # [method] HTTPメソッド
      # [domain] 対象ドメイン
      # [path] APIパス
      # [access_token] トークン
      # [opts] オプション
      #   [:direction] :next or :prev
      #   [:wait] APIコール間にsleepで待機する秒数
      # [headers] 追加ヘッダ
      # [params] GET/POSTパラメータ
      def all!(method, domain, path = nil, access_token = nil, opts = {}, headers = [], **params)
        opts[:direction] ||= :next
        opts[:wait] ||= 1

        while true
          list = API.call!(method, domain, path, access_token, opts, headers, **params)

          if list && list.value.is_a?(Array)
            list.value.each { |hash| yield hash }
          end

          break unless list.header.has_key?(opts[:direction])

          url = list.header[opts[:direction]]
          params = URI.decode_www_form(url.query).to_h.symbolize

          sleep opts[:wait]
        end
      end

      def all_with_world!(world, method, path = nil, opts = {}, headers = [], **params, &block)
        all!(method, world.domain, path, world.access_token, opts, headers, **params, &block)
      end

    end
  end

end