465 lines
13 KiB
Elixir
465 lines
13 KiB
Elixir
# Pleroma: A lightweight social networking server
|
|
# Copyright © 2017-2022 Pleroma Authors <https://pleroma.social/>
|
|
# SPDX-License-Identifier: AGPL-3.0-only
|
|
|
|
defmodule Pleroma.Web.ApiSpec.StreamingOperation do
|
|
alias OpenApiSpex.Operation
|
|
alias OpenApiSpex.Response
|
|
alias OpenApiSpex.Schema
|
|
alias Pleroma.Web.ApiSpec.NotificationOperation
|
|
alias Pleroma.Web.ApiSpec.Schemas.Chat
|
|
alias Pleroma.Web.ApiSpec.Schemas.Conversation
|
|
alias Pleroma.Web.ApiSpec.Schemas.FlakeID
|
|
alias Pleroma.Web.ApiSpec.Schemas.Status
|
|
|
|
require Pleroma.Constants
|
|
|
|
@spec open_api_operation(atom) :: Operation.t()
|
|
def open_api_operation(action) do
|
|
operation = String.to_existing_atom("#{action}_operation")
|
|
apply(__MODULE__, operation, [])
|
|
end
|
|
|
|
@spec streaming_operation() :: Operation.t()
|
|
def streaming_operation do
|
|
%Operation{
|
|
tags: ["Timelines"],
|
|
summary: "Establish streaming connection",
|
|
description: """
|
|
Receive statuses in real-time via WebSocket.
|
|
|
|
You can specify the access token on the query string or through the `sec-websocket-protocol` header. Using
|
|
the query string to authenticate is considered unsafe and should not be used unless you have to (e.g. to maintain
|
|
your client's compatibility with Mastodon).
|
|
|
|
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,
|
|
you must specify the access token at the time of the connection (i.e. via query string or header).
|
|
|
|
Otherwise, you have the option to authenticate after you have established the connection through client-sent events.
|
|
|
|
The "Request body" section below describes what events clients can send through WebSocket, and the "Responses" section
|
|
describes what events server will send through WebSocket.
|
|
""",
|
|
security: [%{"oAuth" => ["read:statuses", "read:notifications"]}],
|
|
operationId: "WebsocketHandler.streaming",
|
|
parameters:
|
|
[
|
|
Operation.parameter(:connection, :header, %Schema{type: :string}, "connection header",
|
|
required: true
|
|
),
|
|
Operation.parameter(:upgrade, :header, %Schema{type: :string}, "upgrade header",
|
|
required: true
|
|
),
|
|
Operation.parameter(
|
|
:"sec-websocket-key",
|
|
:header,
|
|
%Schema{type: :string},
|
|
"sec-websocket-key header",
|
|
required: true
|
|
),
|
|
Operation.parameter(
|
|
:"sec-websocket-version",
|
|
:header,
|
|
%Schema{type: :string},
|
|
"sec-websocket-version header",
|
|
required: true
|
|
)
|
|
] ++ stream_params() ++ access_token_params(),
|
|
requestBody: request_body("Client-sent events", client_sent_events()),
|
|
responses: %{
|
|
101 => switching_protocols_response(),
|
|
200 =>
|
|
Operation.response(
|
|
"Server-sent events",
|
|
"application/json",
|
|
server_sent_events()
|
|
)
|
|
}
|
|
}
|
|
end
|
|
|
|
defp stream_params do
|
|
stream_specifier()
|
|
|> Enum.map(fn {name, schema} ->
|
|
Operation.parameter(name, :query, schema, get_schema(schema).description)
|
|
end)
|
|
end
|
|
|
|
defp access_token_params do
|
|
[
|
|
Operation.parameter(:access_token, :query, token(), token().description),
|
|
Operation.parameter(:"sec-websocket-protocol", :header, token(), token().description)
|
|
]
|
|
end
|
|
|
|
defp switching_protocols_response do
|
|
%Response{
|
|
description: "Switching protocols",
|
|
headers: %{
|
|
"connection" => %OpenApiSpex.Header{required: true},
|
|
"upgrade" => %OpenApiSpex.Header{required: true},
|
|
"sec-websocket-accept" => %OpenApiSpex.Header{required: true}
|
|
}
|
|
}
|
|
end
|
|
|
|
defp server_sent_events do
|
|
%Schema{
|
|
oneOf: [
|
|
update_event(),
|
|
status_update_event(),
|
|
notification_event(),
|
|
chat_update_event(),
|
|
follow_relationships_update_event(),
|
|
conversation_event(),
|
|
delete_event(),
|
|
pleroma_respond_event()
|
|
]
|
|
}
|
|
end
|
|
|
|
defp stream do
|
|
%Schema{
|
|
type: :array,
|
|
title: "Stream",
|
|
description: """
|
|
The stream identifier.
|
|
The first item is the name of the stream. If the stream needs a differentiator, the second item will be the corresponding identifier.
|
|
Currently, for the following stream types, there is a second element in the array:
|
|
|
|
- `list`: The second element is the id of the list, as a string.
|
|
- `hashtag`: The second element is the name of the hashtag.
|
|
- `public:remote:media` and `public:remote`: The second element is the domain of the corresponding instance.
|
|
""",
|
|
maxItems: 2,
|
|
minItems: 1,
|
|
items: %Schema{type: :string},
|
|
example: ["hashtag", "mew"]
|
|
}
|
|
end
|
|
|
|
defp get_schema(%Schema{} = schema), do: schema
|
|
defp get_schema(schema), do: schema.schema
|
|
|
|
defp server_sent_event_helper(name, description, type, payload, opts \\ []) do
|
|
payload_type = Keyword.get(opts, :payload_type, :json)
|
|
has_stream = Keyword.get(opts, :has_stream, true)
|
|
|
|
stream_properties =
|
|
if has_stream do
|
|
%{stream: stream()}
|
|
else
|
|
%{}
|
|
end
|
|
|
|
stream_example = if has_stream, do: %{"stream" => get_schema(stream()).example}, else: %{}
|
|
|
|
stream_required = if has_stream, do: [:stream], else: []
|
|
|
|
payload_schema =
|
|
if payload_type == :json do
|
|
%Schema{
|
|
title: "Event payload",
|
|
description: "JSON-encoded string of #{get_schema(payload).title}",
|
|
allOf: [payload]
|
|
}
|
|
else
|
|
payload
|
|
end
|
|
|
|
payload_example =
|
|
if payload_type == :json do
|
|
get_schema(payload).example |> Jason.encode!()
|
|
else
|
|
get_schema(payload).example
|
|
end
|
|
|
|
%Schema{
|
|
type: :object,
|
|
title: name,
|
|
description: description,
|
|
required: [:event, :payload] ++ stream_required,
|
|
properties:
|
|
%{
|
|
event: %Schema{
|
|
title: "Event type",
|
|
description: "Type of the event.",
|
|
type: :string,
|
|
required: true,
|
|
enum: [type]
|
|
},
|
|
payload: payload_schema
|
|
}
|
|
|> Map.merge(stream_properties),
|
|
example:
|
|
%{
|
|
"event" => type,
|
|
"payload" => payload_example
|
|
}
|
|
|> Map.merge(stream_example)
|
|
}
|
|
end
|
|
|
|
defp update_event do
|
|
server_sent_event_helper("New status", "A newly-posted status.", "update", Status)
|
|
end
|
|
|
|
defp status_update_event do
|
|
server_sent_event_helper("Edit", "A status that was just edited", "status.update", Status)
|
|
end
|
|
|
|
defp notification_event do
|
|
server_sent_event_helper(
|
|
"Notification",
|
|
"A new notification.",
|
|
"notification",
|
|
NotificationOperation.notification()
|
|
)
|
|
end
|
|
|
|
defp follow_relationships_update_event do
|
|
server_sent_event_helper(
|
|
"Follow relationships update",
|
|
"An update to follow relationships.",
|
|
"pleroma:follow_relationships_update",
|
|
%Schema{
|
|
type: :object,
|
|
title: "Follow relationships update",
|
|
required: [:state, :follower, :following],
|
|
properties: %{
|
|
state: %Schema{
|
|
type: :string,
|
|
description: "Follow state of the relationship.",
|
|
enum: ["follow_pending", "follow_accept", "follow_reject", "unfollow"]
|
|
},
|
|
follower: %Schema{
|
|
type: :object,
|
|
description: "Information about the follower.",
|
|
required: [:id, :follower_count, :following_count],
|
|
properties: %{
|
|
id: FlakeID,
|
|
follower_count: %Schema{type: :integer},
|
|
following_count: %Schema{type: :integer}
|
|
}
|
|
},
|
|
following: %Schema{
|
|
type: :object,
|
|
description: "Information about the following person.",
|
|
required: [:id, :follower_count, :following_count],
|
|
properties: %{
|
|
id: FlakeID,
|
|
follower_count: %Schema{type: :integer},
|
|
following_count: %Schema{type: :integer}
|
|
}
|
|
}
|
|
},
|
|
example: %{
|
|
"state" => "follow_pending",
|
|
"follower" => %{
|
|
"id" => "someUser1",
|
|
"follower_count" => 1,
|
|
"following_count" => 1
|
|
},
|
|
"following" => %{
|
|
"id" => "someUser2",
|
|
"follower_count" => 1,
|
|
"following_count" => 1
|
|
}
|
|
}
|
|
}
|
|
)
|
|
end
|
|
|
|
defp chat_update_event do
|
|
server_sent_event_helper(
|
|
"Chat update",
|
|
"A new chat message.",
|
|
"pleroma:chat_update",
|
|
Chat
|
|
)
|
|
end
|
|
|
|
defp conversation_event do
|
|
server_sent_event_helper(
|
|
"Conversation update",
|
|
"An update about a conversation",
|
|
"conversation",
|
|
Conversation
|
|
)
|
|
end
|
|
|
|
defp delete_event do
|
|
server_sent_event_helper(
|
|
"Delete",
|
|
"A status that was just deleted.",
|
|
"delete",
|
|
%Schema{
|
|
type: :string,
|
|
title: "Status id",
|
|
description: "Id of the deleted status",
|
|
allOf: [FlakeID],
|
|
example: "some-opaque-id"
|
|
},
|
|
payload_type: :string,
|
|
has_stream: false
|
|
)
|
|
end
|
|
|
|
defp pleroma_respond_event do
|
|
server_sent_event_helper(
|
|
"Server response",
|
|
"A response to a client-sent event.",
|
|
"pleroma:respond",
|
|
%Schema{
|
|
type: :object,
|
|
title: "Results",
|
|
required: [:result, :type],
|
|
properties: %{
|
|
result: %Schema{
|
|
type: :string,
|
|
title: "Result of the request",
|
|
enum: ["success", "error", "ignored"]
|
|
},
|
|
error: %Schema{
|
|
type: :string,
|
|
title: "Error code",
|
|
description: "An error identifier. Only appears if `result` is `error`."
|
|
},
|
|
type: %Schema{
|
|
type: :string,
|
|
description: "Type of the request."
|
|
}
|
|
},
|
|
example: %{"result" => "success", "type" => "pleroma:authenticate"}
|
|
},
|
|
has_stream: false
|
|
)
|
|
end
|
|
|
|
defp client_sent_events do
|
|
%Schema{
|
|
oneOf: [
|
|
subscribe_event(),
|
|
unsubscribe_event(),
|
|
authenticate_event()
|
|
]
|
|
}
|
|
end
|
|
|
|
defp request_body(description, schema, opts \\ []) do
|
|
%OpenApiSpex.RequestBody{
|
|
description: description,
|
|
content: %{
|
|
"application/json" => %OpenApiSpex.MediaType{
|
|
schema: schema,
|
|
example: opts[:example],
|
|
examples: opts[:examples]
|
|
}
|
|
}
|
|
}
|
|
end
|
|
|
|
defp client_sent_event_helper(name, description, type, properties, opts) do
|
|
required = opts[:required] || []
|
|
|
|
%Schema{
|
|
type: :object,
|
|
title: name,
|
|
required: [:type] ++ required,
|
|
description: description,
|
|
properties:
|
|
%{
|
|
type: %Schema{type: :string, enum: [type], description: "Type of the event."}
|
|
}
|
|
|> Map.merge(properties),
|
|
example: opts[:example]
|
|
}
|
|
end
|
|
|
|
defp subscribe_event do
|
|
client_sent_event_helper(
|
|
"Subscribe",
|
|
"Subscribe to a stream.",
|
|
"subscribe",
|
|
stream_specifier(),
|
|
required: [:stream],
|
|
example: %{"type" => "subscribe", "stream" => "list", "list" => "1"}
|
|
)
|
|
end
|
|
|
|
defp unsubscribe_event do
|
|
client_sent_event_helper(
|
|
"Unsubscribe",
|
|
"Unsubscribe from a stream.",
|
|
"unsubscribe",
|
|
stream_specifier(),
|
|
required: [:stream],
|
|
example: %{
|
|
"type" => "unsubscribe",
|
|
"stream" => "public:remote:media",
|
|
"instance" => "example.org"
|
|
}
|
|
)
|
|
end
|
|
|
|
defp authenticate_event do
|
|
client_sent_event_helper(
|
|
"Authenticate",
|
|
"Authenticate via an access token.",
|
|
"pleroma:authenticate",
|
|
%{
|
|
token: token()
|
|
},
|
|
required: [:token]
|
|
)
|
|
end
|
|
|
|
defp token do
|
|
%Schema{
|
|
type: :string,
|
|
description: "An OAuth access token with corresponding permissions.",
|
|
example: "some token"
|
|
}
|
|
end
|
|
|
|
defp stream_specifier do
|
|
%{
|
|
stream: %Schema{
|
|
type: :string,
|
|
description: "The name of the stream.",
|
|
enum:
|
|
Pleroma.Constants.public_streams() ++
|
|
[
|
|
"public:remote",
|
|
"public:remote:media",
|
|
"user",
|
|
"user:pleroma_chat",
|
|
"user:notification",
|
|
"direct",
|
|
"list",
|
|
"hashtag"
|
|
]
|
|
},
|
|
list: %Schema{
|
|
type: :string,
|
|
title: "List id",
|
|
description: "The id of the list. Required when `stream` is `list`.",
|
|
example: "some-id"
|
|
},
|
|
tag: %Schema{
|
|
type: :string,
|
|
title: "Hashtag name",
|
|
description: "The name of the hashtag. Required when `stream` is `hashtag`.",
|
|
example: "mew"
|
|
},
|
|
instance: %Schema{
|
|
type: :string,
|
|
title: "Domain name",
|
|
description:
|
|
"Domain name of the instance. Required when `stream` is `public:remote` or `public:remote:media`.",
|
|
example: "example.org"
|
|
}
|
|
}
|
|
end
|
|
end
|