save: add utoipa for OAPI generation

This commit is contained in:
oxalica 2024-10-16 06:43:54 -04:00
parent 71c5f038fa
commit acaf0f955a
11 changed files with 212 additions and 610 deletions

View file

@ -6,6 +6,11 @@ edition = "2021"
[features]
default = []
unsafe_use_mock_instant_for_testing = ["dep:mock_instant"]
utoipa = ["dep:utoipa"]
[[example]]
name = "openapi"
required-features = ["utoipa"]
[[bench]]
name = "crypto_ops"
@ -26,6 +31,7 @@ serde_json = "1"
serde_with = "3"
thiserror = "1"
url = { version = "2", features = ["serde"] }
utoipa = { workspace = true, optional = true, features = ["url"] } # Generics support.
[dev-dependencies]
criterion = "0.5"

View file

@ -0,0 +1,8 @@
#![expect(clippy::print_stdout, reason = "allowed to dump OAPI")]
fn main() {
let json = blah_types::openapi()
.to_pretty_json()
.expect("serialization cannot fail");
println!("{json}");
}

View file

@ -11,6 +11,7 @@ use rand::RngCore;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct UserKey {
pub id_key: PubKey,
pub act_key: PubKey,
@ -18,6 +19,7 @@ pub struct UserKey {
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = String))]
pub struct PubKey(#[serde(with = "hex::serde")] pub [u8; PUBLIC_KEY_LENGTH]);
impl FromStr for PubKey {
@ -55,14 +57,17 @@ impl From<&VerifyingKey> for PubKey {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Signed<T> {
#[serde(with = "hex::serde")]
#[cfg_attr(feature = "utoipa", schema(value_type = String))]
pub sig: [u8; SIGNATURE_LENGTH],
pub signee: Signee<T>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(deny_unknown_fields)]
pub struct Signee<T> {
pub nonce: u32,

View file

@ -12,6 +12,7 @@ use crate::{PubKey, Signed};
/// User identity description structure.
// TODO: Revise and shrink duplicates (pubkey fields).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct UserIdentityDesc {
/// User primary identity key, only for signing action keys.
pub id_key: PubKey,
@ -90,6 +91,7 @@ impl UserIdentityDesc {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "user_act_key")]
pub struct UserActKeyDesc {
pub act_key: PubKey,
@ -98,6 +100,7 @@ pub struct UserActKeyDesc {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "user_profile")]
pub struct UserProfile {
pub preferred_chat_server_urls: Vec<Url>,
@ -105,6 +108,7 @@ pub struct UserProfile {
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = Url))]
#[serde(try_from = "Url")]
pub struct IdUrl(Url);

View file

@ -10,3 +10,46 @@ pub mod crypto;
pub mod identity;
pub mod msg;
pub mod server;
#[cfg(feature = "utoipa")]
pub fn openapi() -> utoipa::openapi::OpenApi {
use utoipa::OpenApi;
#[derive(OpenApi)]
#[openapi(components(schemas(
crypto::Signed::<msg::AuthPayload>,
crypto::Signed::<msg::ChatPayload>,
crypto::Signed::<msg::CreateRoomPayload>,
crypto::Signed::<msg::DeleteRoomPayload>,
crypto::Signed::<msg::RoomAdminPayload>,
crypto::Signed::<msg::UserRegisterPayload>,
identity::UserIdentityDesc,
identity::UserProfile,
msg::AuthPayload,
msg::ChatPayload,
msg::DeleteRoomPayload,
msg::RichText,
msg::RoomAdminPayload,
msg::UserRegisterPayload,
server::ClientEvent,
server::ErrorResponse,
server::ErrorResponseWithChallenge,
server::RoomList,
server::RoomMetadata,
server::RoomMsgs,
server::ServerCapabilities,
server::ServerEvent,
server::ServerMetadata,
)))]
struct ApiDoc;
ApiDoc::openapi()
}
#[cfg(feature = "utoipa")]
#[test]
#[expect(clippy::print_stdout, reason = "allowed in tests")]
fn test_openapi() {
let json = crate::openapi().to_pretty_json().unwrap();
println!("{json}");
}

View file

@ -15,6 +15,7 @@ use crate::{PubKey, Signed};
/// It's currently serialized as a string for JavaScript's convenience.
#[serde_as]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = String))]
#[serde(transparent)]
pub struct Id(#[serde_as(as = "DisplayFromStr")] pub i64);
@ -39,6 +40,7 @@ impl Id {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct WithMsgId<T> {
pub cid: Id,
#[serde(flatten)]
@ -53,17 +55,20 @@ impl<T> WithMsgId<T> {
/// Register a user on a chat server.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "user_register")]
pub struct UserRegisterPayload {
pub server_url: Url,
pub id_url: IdUrl,
pub id_key: PubKey,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub challenge: Option<UserRegisterChallengeResponse>,
}
/// The server-specific challenge data for registration.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum UserRegisterChallengeResponse {
/// Proof of work challenge containing the same nonce from server challenge request.
@ -73,6 +78,7 @@ pub enum UserRegisterChallengeResponse {
// FIXME: `deny_unknown_fields` breaks this.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "chat")]
pub struct ChatPayload {
pub rich_text: RichText,
@ -81,6 +87,7 @@ pub struct ChatPayload {
/// Ref: <https://github.com/Blah-IM/Weblah/blob/a3fa0f265af54c846f8d65f42aa4409c8dba9dd9/src/lib/richText.ts>
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = Vec<RichTextPieceRaw>))]
#[serde(transparent)]
pub struct RichText(pub Vec<RichTextPiece>);
@ -103,8 +110,9 @@ impl Serialize for RichTextPiece {
}
}
/// The protocol representation of `RichTextPiece`.
/// The representation on wire of `RichTextPiece`.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(untagged)]
enum RichTextPieceRaw {
Text(String),
@ -148,6 +156,7 @@ impl<'de> Deserialize<'de> for RichText {
// TODO: This protocol format is quite large. Could use bitflags for database.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct TextAttrs {
#[serde(default, rename = "b", skip_serializing_if = "is_default")]
pub bold: bool,
@ -159,6 +168,7 @@ pub struct TextAttrs {
pub italic: bool,
// TODO: Should we validate and/or filter the URL.
#[serde(default, skip_serializing_if = "is_default")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub link: Option<String>,
#[serde(default, rename = "s", skip_serializing_if = "is_default")]
pub strike: bool,
@ -271,6 +281,7 @@ pub type SignedChatMsg = Signed<ChatPayload>;
pub type SignedChatMsgWithId = WithMsgId<SignedChatMsg>;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ")]
pub enum CreateRoomPayload {
#[serde(rename = "create_room")]
@ -281,6 +292,7 @@ pub enum CreateRoomPayload {
/// Multi-user room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct CreateGroup {
pub attrs: RoomAttrs,
pub title: String,
@ -288,11 +300,13 @@ pub struct CreateGroup {
/// Peer-to-peer chat room with exactly two symmetric users.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct CreatePeerChat {
pub peer: PubKey,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "delete_room")]
pub struct DeleteRoomPayload {
pub room: Id,
@ -302,6 +316,7 @@ pub struct DeleteRoomPayload {
/// 1. Sorted by userkeys.
/// 2. No duplicated users.
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(try_from = "Vec<RoomMember>")]
pub struct RoomMemberList(pub Vec<RoomMember>);
@ -327,6 +342,7 @@ impl TryFrom<Vec<RoomMember>> for RoomMemberList {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomMember {
pub permission: MemberPermission,
pub user: PubKey,
@ -336,11 +352,13 @@ pub struct RoomMember {
///
/// TODO: Should we use JWT here instead?
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename = "auth")]
pub struct AuthPayload {}
// FIXME: Remove this.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
// `typ` is provided by `RoomAdminOp`.
pub struct RoomAdminPayload {
#[serde(flatten)]
@ -348,6 +366,7 @@ pub struct RoomAdminPayload {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename_all = "snake_case", rename = "remove_member")]
pub struct RemoveMemberPayload {
pub room: Id,
@ -357,6 +376,7 @@ pub struct RemoveMemberPayload {
// TODO: Maybe disallow adding other user without consent?
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename_all = "snake_case", rename = "add_member")]
pub struct AddMemberPayload {
pub room: Id,
@ -365,6 +385,7 @@ pub struct AddMemberPayload {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(tag = "typ", rename_all = "snake_case", rename = "update_member")]
pub struct UpdateMemberPayload {
pub room: Id,
@ -373,6 +394,7 @@ pub struct UpdateMemberPayload {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(untagged)]
pub enum RoomAdminOp {
AddMember(AddMemberPayload),
@ -382,6 +404,7 @@ pub enum RoomAdminOp {
bitflags::bitflags! {
/// TODO: Is this a really all about permission, or is a generic `UserFlags`?
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = i32))]
pub struct ServerPermission: i32 {
const CREATE_ROOM = 1 << 0;
@ -391,6 +414,7 @@ bitflags::bitflags! {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = i32))]
pub struct MemberPermission: i32 {
const POST_CHAT = 1 << 0;
const ADD_MEMBER = 1 << 1;
@ -410,6 +434,7 @@ bitflags::bitflags! {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema), schema(value_type = i32))]
pub struct RoomAttrs: i32 {
// NB. Used by schema.
const PUBLIC_READABLE = 1 << 0;

View file

@ -10,6 +10,7 @@ use crate::PubKey;
/// The response object returned as body on HTTP error status.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ErrorResponse<S = String> {
/// The error object.
pub error: ErrorObject<S>,
@ -18,21 +19,27 @@ pub struct ErrorResponse<S = String> {
/// The response object of `/_blah/user/me` endpoint on HTTP error status.
/// It contains additional registration information.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ErrorResponseWithChallenge<S = String> {
/// The error object.
pub error: ErrorObject<S>,
/// The challenge metadata returned by the `/_blah/user/me` endpoint for registration.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub register_challenge: Option<UserRegisterChallenge>,
}
/// The error object.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ErrorObject<S = String> {
/// A machine-readable error code string.
#[cfg_attr(feature = "utoipa", schema(value_type = String, example = "user_not_found"))]
pub code: S,
/// A human-readable error message.
#[cfg_attr(feature = "utoipa", schema(value_type = String, example = "the user does not exist"))]
pub message: S,
}
@ -50,16 +57,20 @@ impl<S: fmt::Display + fmt::Debug> std::error::Error for ErrorObject<S> {}
/// It may contains extra fields and clients should ignore them for future compatibility.
/// Chat Servers can also include any custom fields here as long they have a `_` prefix.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ServerMetadata {
/// A server-defined version string indicating its implementation name and the version.
///
/// It is expected to be in form `<server-name>/<server-version>` but not mandatory.
#[cfg_attr(feature = "utoipa", schema(example = "blahd/0.0.1"))]
pub server: String,
/// The URL to the source code of the Chat Server.
///
/// It is expected to be a public accessible maybe-compressed tarball link without
/// access control.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub src_url: Option<Url>,
/// The server capabilities set.
@ -67,6 +78,7 @@ pub struct ServerMetadata {
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct ServerCapabilities {
/// Whether registration is open to public.
pub allow_public_register: bool,
@ -74,6 +86,7 @@ pub struct ServerCapabilities {
/// Registration challenge information.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum UserRegisterChallenge {
/// Proof-of-work (PoW) challenge.
@ -86,67 +99,82 @@ pub enum UserRegisterChallenge {
/// Response to list rooms.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomList {
/// Result list of rooms.
pub rooms: Vec<RoomMetadata>,
/// The skip-token to fetch the next page.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub skip_token: Option<Id>,
}
/// The metadata of a room.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomMetadata {
/// Room id.
pub rid: Id,
/// Plain text room title. None for peer chat.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub title: Option<String>,
/// Room attributes.
pub attrs: RoomAttrs,
// Extra information is only available for some APIs.
/// The last message in the room.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub last_msg: Option<SignedChatMsgWithId>,
/// The current user's last seen message's `cid`.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub last_seen_cid: Option<Id>,
/// The number of unseen messages, ie. the number of messages from `last_seen_cid` to
/// `last_msg.cid`.
/// This may or may not be a precise number.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub unseen_cnt: Option<u32>,
/// The member permission of current user in the room, or `None` if it is not a member.
/// Only available with authentication.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub member_permission: Option<MemberPermission>,
/// The peer user, if this is a peer chat room.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub peer_user: Option<PubKey>,
}
/// Response to list room msgs.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomMsgs {
/// Result list of msgs.
pub msgs: Vec<SignedChatMsgWithId>,
/// The skip-token to fetch the next page.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub skip_token: Option<Id>,
}
/// Response to list room members.
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomMemberList {
/// Result list of members.
pub members: Vec<RoomMember>,
/// The skip-token to fetch the next page.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub skip_token: Option<Id>,
}
/// The description of a room member.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
pub struct RoomMember {
/// The identity key of the member user.
pub id_key: PubKey,
@ -154,11 +182,13 @@ pub struct RoomMember {
pub permission: MemberPermission,
/// The user's last seen message `cid` in the room.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[cfg_attr(feature = "utoipa", schema(nullable = false))]
pub last_seen_cid: Option<Id>,
}
/// A server-to-client event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ServerEvent {
/// A message from a joined room.
@ -170,5 +200,6 @@ pub enum ServerEvent {
/// A client-to-server event.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum ClientEvent {}