total rebase
[anni] / lib / pleroma / web / api_spec / operations / streaming_operation.ex
1 # Pleroma: A lightweight social networking server
2 # Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
3 # SPDX-License-Identifier: AGPL-3.0-only
4
5 defmodule Pleroma.Web.ApiSpec.StreamingOperation do
6   alias OpenApiSpex.Operation
7   alias OpenApiSpex.Response
8   alias OpenApiSpex.Schema
9   alias Pleroma.Web.ApiSpec.NotificationOperation
10   alias Pleroma.Web.ApiSpec.Schemas.Chat
11   alias Pleroma.Web.ApiSpec.Schemas.Conversation
12   alias Pleroma.Web.ApiSpec.Schemas.FlakeID
13   alias Pleroma.Web.ApiSpec.Schemas.Status
14
15   require Pleroma.Constants
16
17   @spec open_api_operation(atom) :: Operation.t()
18   def open_api_operation(action) do
19     operation = String.to_existing_atom("#{action}_operation")
20     apply(__MODULE__, operation, [])
21   end
22
23   @spec streaming_operation() :: Operation.t()
24   def streaming_operation do
25     %Operation{
26       tags: ["Timelines"],
27       summary: "Establish streaming connection",
28       description: """
29       Receive statuses in real-time via WebSocket.
30
31       You can specify the access token on the query string or through the `sec-websocket-protocol` header. Using
32       the query string to authenticate is considered unsafe and should not be used unless you have to (e.g. to maintain
33       your client's compatibility with Mastodon).
34
35       You may specify a stream on the query string. If you do so and you are connecting to a stream that requires logged-in users,
36       you must specify the access token at the time of the connection (i.e. via query string or header).
37
38       Otherwise, you have the option to authenticate after you have established the connection through client-sent events.
39
40       The "Request body" section below describes what events clients can send through WebSocket, and the "Responses" section
41       describes what events server will send through WebSocket.
42       """,
43       security: [%{"oAuth" => ["read:statuses", "read:notifications"]}],
44       operationId: "WebsocketHandler.streaming",
45       parameters:
46         [
47           Operation.parameter(:connection, :header, %Schema{type: :string}, "connection header",
48             required: true
49           ),
50           Operation.parameter(:upgrade, :header, %Schema{type: :string}, "upgrade header",
51             required: true
52           ),
53           Operation.parameter(
54             :"sec-websocket-key",
55             :header,
56             %Schema{type: :string},
57             "sec-websocket-key header",
58             required: true
59           ),
60           Operation.parameter(
61             :"sec-websocket-version",
62             :header,
63             %Schema{type: :string},
64             "sec-websocket-version header",
65             required: true
66           )
67         ] ++ stream_params() ++ access_token_params(),
68       requestBody: request_body("Client-sent events", client_sent_events()),
69       responses: %{
70         101 => switching_protocols_response(),
71         200 =>
72           Operation.response(
73             "Server-sent events",
74             "application/json",
75             server_sent_events()
76           )
77       }
78     }
79   end
80
81   defp stream_params do
82     stream_specifier()
83     |> Enum.map(fn {name, schema} ->
84       Operation.parameter(name, :query, schema, get_schema(schema).description)
85     end)
86   end
87
88   defp access_token_params do
89     [
90       Operation.parameter(:access_token, :query, token(), token().description),
91       Operation.parameter(:"sec-websocket-protocol", :header, token(), token().description)
92     ]
93   end
94
95   defp switching_protocols_response do
96     %Response{
97       description: "Switching protocols",
98       headers: %{
99         "connection" => %OpenApiSpex.Header{required: true},
100         "upgrade" => %OpenApiSpex.Header{required: true},
101         "sec-websocket-accept" => %OpenApiSpex.Header{required: true}
102       }
103     }
104   end
105
106   defp server_sent_events do
107     %Schema{
108       oneOf: [
109         update_event(),
110         status_update_event(),
111         notification_event(),
112         chat_update_event(),
113         follow_relationships_update_event(),
114         conversation_event(),
115         delete_event(),
116         pleroma_respond_event()
117       ]
118     }
119   end
120
121   defp stream do
122     %Schema{
123       type: :array,
124       title: "Stream",
125       description: """
126       The stream identifier.
127       The first item is the name of the stream. If the stream needs a differentiator, the second item will be the corresponding identifier.
128       Currently, for the following stream types, there is a second element in the array:
129
130       - `list`: The second element is the id of the list, as a string.
131       - `hashtag`: The second element is the name of the hashtag.
132       - `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance.
133       """,
134       maxItems: 2,
135       minItems: 1,
136       items: %Schema{type: :string},
137       example: ["hashtag", "mew"]
138     }
139   end
140
141   defp get_schema(%Schema{} = schema), do: schema
142   defp get_schema(schema), do: schema.schema
143
144   defp server_sent_event_helper(name, description, type, payload, opts \\ []) do
145     payload_type = Keyword.get(opts, :payload_type, :json)
146     has_stream = Keyword.get(opts, :has_stream, true)
147
148     stream_properties =
149       if has_stream do
150         %{stream: stream()}
151       else
152         %{}
153       end
154
155     stream_example = if has_stream, do: %{"stream" => get_schema(stream()).example}, else: %{}
156
157     stream_required = if has_stream, do: [:stream], else: []
158
159     payload_schema =
160       if payload_type == :json do
161         %Schema{
162           title: "Event payload",
163           description: "JSON-encoded string of #{get_schema(payload).title}",
164           allOf: [payload]
165         }
166       else
167         payload
168       end
169
170     payload_example =
171       if payload_type == :json do
172         get_schema(payload).example |> Jason.encode!()
173       else
174         get_schema(payload).example
175       end
176
177     %Schema{
178       type: :object,
179       title: name,
180       description: description,
181       required: [:event, :payload] ++ stream_required,
182       properties:
183         %{
184           event: %Schema{
185             title: "Event type",
186             description: "Type of the event.",
187             type: :string,
188             required: true,
189             enum: [type]
190           },
191           payload: payload_schema
192         }
193         |> Map.merge(stream_properties),
194       example:
195         %{
196           "event" => type,
197           "payload" => payload_example
198         }
199         |> Map.merge(stream_example)
200     }
201   end
202
203   defp update_event do
204     server_sent_event_helper("New status", "A newly-posted status.", "update", Status)
205   end
206
207   defp status_update_event do
208     server_sent_event_helper("Edit", "A status that was just edited", "status.update", Status)
209   end
210
211   defp notification_event do
212     server_sent_event_helper(
213       "Notification",
214       "A new notification.",
215       "notification",
216       NotificationOperation.notification()
217     )
218   end
219
220   defp follow_relationships_update_event do
221     server_sent_event_helper(
222       "Follow relationships update",
223       "An update to follow relationships.",
224       "pleroma:follow_relationships_update",
225       %Schema{
226         type: :object,
227         title: "Follow relationships update",
228         required: [:state, :follower, :following],
229         properties: %{
230           state: %Schema{
231             type: :string,
232             description: "Follow state of the relationship.",
233             enum: ["follow_pending", "follow_accept", "follow_reject", "unfollow"]
234           },
235           follower: %Schema{
236             type: :object,
237             description: "Information about the follower.",
238             required: [:id, :follower_count, :following_count],
239             properties: %{
240               id: FlakeID,
241               follower_count: %Schema{type: :integer},
242               following_count: %Schema{type: :integer}
243             }
244           },
245           following: %Schema{
246             type: :object,
247             description: "Information about the following person.",
248             required: [:id, :follower_count, :following_count],
249             properties: %{
250               id: FlakeID,
251               follower_count: %Schema{type: :integer},
252               following_count: %Schema{type: :integer}
253             }
254           }
255         },
256         example: %{
257           "state" => "follow_pending",
258           "follower" => %{
259             "id" => "someUser1",
260             "follower_count" => 1,
261             "following_count" => 1
262           },
263           "following" => %{
264             "id" => "someUser2",
265             "follower_count" => 1,
266             "following_count" => 1
267           }
268         }
269       }
270     )
271   end
272
273   defp chat_update_event do
274     server_sent_event_helper(
275       "Chat update",
276       "A new chat message.",
277       "pleroma:chat_update",
278       Chat
279     )
280   end
281
282   defp conversation_event do
283     server_sent_event_helper(
284       "Conversation update",
285       "An update about a conversation",
286       "conversation",
287       Conversation
288     )
289   end
290
291   defp delete_event do
292     server_sent_event_helper(
293       "Delete",
294       "A status that was just deleted.",
295       "delete",
296       %Schema{
297         type: :string,
298         title: "Status id",
299         description: "Id of the deleted status",
300         allOf: [FlakeID],
301         example: "some-opaque-id"
302       },
303       payload_type: :string,
304       has_stream: false
305     )
306   end
307
308   defp pleroma_respond_event do
309     server_sent_event_helper(
310       "Server response",
311       "A response to a client-sent event.",
312       "pleroma:respond",
313       %Schema{
314         type: :object,
315         title: "Results",
316         required: [:result, :type],
317         properties: %{
318           result: %Schema{
319             type: :string,
320             title: "Result of the request",
321             enum: ["success", "error", "ignored"]
322           },
323           error: %Schema{
324             type: :string,
325             title: "Error code",
326             description: "An error identifier. Only appears if `result` is `error`."
327           },
328           type: %Schema{
329             type: :string,
330             description: "Type of the request."
331           }
332         },
333         example: %{"result" => "success", "type" => "pleroma:authenticate"}
334       },
335       has_stream: false
336     )
337   end
338
339   defp client_sent_events do
340     %Schema{
341       oneOf: [
342         subscribe_event(),
343         unsubscribe_event(),
344         authenticate_event()
345       ]
346     }
347   end
348
349   defp request_body(description, schema, opts \\ []) do
350     %OpenApiSpex.RequestBody{
351       description: description,
352       content: %{
353         "application/json" => %OpenApiSpex.MediaType{
354           schema: schema,
355           example: opts[:example],
356           examples: opts[:examples]
357         }
358       }
359     }
360   end
361
362   defp client_sent_event_helper(name, description, type, properties, opts) do
363     required = opts[:required] || []
364
365     %Schema{
366       type: :object,
367       title: name,
368       required: [:type] ++ required,
369       description: description,
370       properties:
371         %{
372           type: %Schema{type: :string, enum: [type], description: "Type of the event."}
373         }
374         |> Map.merge(properties),
375       example: opts[:example]
376     }
377   end
378
379   defp subscribe_event do
380     client_sent_event_helper(
381       "Subscribe",
382       "Subscribe to a stream.",
383       "subscribe",
384       stream_specifier(),
385       required: [:stream],
386       example: %{"type" => "subscribe", "stream" => "list", "list" => "1"}
387     )
388   end
389
390   defp unsubscribe_event do
391     client_sent_event_helper(
392       "Unsubscribe",
393       "Unsubscribe from a stream.",
394       "unsubscribe",
395       stream_specifier(),
396       required: [:stream],
397       example: %{
398         "type" => "unsubscribe",
399         "stream" => "public:remote:media",
400         "instance" => "example.org"
401       }
402     )
403   end
404
405   defp authenticate_event do
406     client_sent_event_helper(
407       "Authenticate",
408       "Authenticate via an access token.",
409       "pleroma:authenticate",
410       %{
411         token: token()
412       },
413       required: [:token]
414     )
415   end
416
417   defp token do
418     %Schema{
419       type: :string,
420       description: "An OAuth access token with corresponding permissions.",
421       example: "some token"
422     }
423   end
424
425   defp stream_specifier do
426     %{
427       stream: %Schema{
428         type: :string,
429         description: "The name of the stream.",
430         enum:
431           Pleroma.Constants.public_streams() ++
432             [
433               "public:remote",
434               "public:remote:media",
435               "user",
436               "user:pleroma_chat",
437               "user:notification",
438               "direct",
439               "list",
440               "hashtag"
441             ]
442       },
443       list: %Schema{
444         type: :string,
445         title: "List id",
446         description: "The id of the list. Required when `stream` is `list`.",
447         example: "some-id"
448       },
449       tag: %Schema{
450         type: :string,
451         title: "Hashtag name",
452         description: "The name of the hashtag. Required when `stream` is `hashtag`.",
453         example: "mew"
454       },
455       instance: %Schema{
456         type: :string,
457         title: "Domain name",
458         description:
459           "Domain name of the instance. Required when `stream` is `public:remote` or `public:remote:media`.",
460         example: "example.org"
461       }
462     }
463   end
464 end