use std::collections::BTreeSet;
use knot_record::Prose;
use knot_record::artifact::{ArtifactBlob, artifact_collection};
use knot_record::comment::CommentRef;
use knot_record::comment::{COMMIT_EMBED_TYPE, CommentMarkup, comment_collection};
use knot_record::label::{LabelColor, LabelDef as RecordLabelDef, LabelName, LabelToken};
use knot_record::manifest::{ManifestSource, RepoDescription, RepoTopics};
use knot_record::pull::patch_collection;
use knot_record::reaction::{ReactionKind, reaction_collection};
use knot_record::ticket::{
MAX_ASSIGNEES, MarkdownBody, TargetBranch, TicketState, TicketTitle as RecordTicketTitle,
ticket_collection,
};
use knot_types::{
AccountDid, AtUri, BranchName, ContributionPolicy, Oid, OwnerDid, RecordAddress, RecordCid,
RecordCollection, RecordRkey, RepoDid, RepoName, ServiceDid, TypeName,
};
use serde::Deserialize;
use serde::de::DeserializeOwned;
use super::error::{CidString, WriteError};
/// a label key may be `.` or `..`, which no at-uri names, and composing one panics.
fn uri_rkey<E: serde::de::Error>(raw: String) -> Result<RecordRkey, E> {
knot_types::UriRkey::new(raw.as_str()).map_err(E::custom)?;
RecordRkey::new(raw).map_err(E::custom)
}
fn record_key<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<RecordRkey, D::Error> {
uri_rkey(String::deserialize(deserializer)?)
}
fn optional_record_key<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<RecordRkey>, D::Error> {
Option::<String>::deserialize(deserializer)?
.map(uri_rkey)
.transpose()
}
#[derive(Deserialize)]
pub(super) struct CreateRecordInput {
pub(super) repo: RepoDid,
pub(super) collection: RecordCollection,
#[serde(default, deserialize_with = "optional_record_key")]
pub(super) rkey: Option<RecordRkey>,
pub(super) record: serde_json::Value,
}
#[derive(Deserialize)]
pub(super) struct SwapInput {
pub(super) repo: RepoDid,
pub(super) collection: RecordCollection,
#[serde(deserialize_with = "record_key")]
pub(super) rkey: RecordRkey,
#[serde(rename = "swapRecord", default)]
pub(super) swap_record: Option<CidString>,
#[serde(rename = "swapCommit", default)]
pub(super) swap_commit: Option<CidString>,
}
impl SwapInput {
pub(super) fn edit_of(
self,
editable: &RecordCollection,
) -> Result<(RepoDid, RecordRkey, RecordCid), WriteError> {
if self.swap_commit.is_some() {
return Err(no_swap_commit());
}
if &self.collection != editable {
return Err(rejected_collection(&self.collection));
}
let from = self
.swap_record
.and_then(|swap| swap.record())
.ok_or_else(|| {
WriteError::invalid_request(
"swapRecord must be the dag-cbor CID of a record version",
)
})?;
Ok((self.repo, self.rkey, from))
}
}
#[derive(Deserialize)]
pub(super) struct PutRecordInput {
#[serde(flatten)]
pub(super) swap: SwapInput,
pub(super) record: serde_json::Value,
}
pub(super) fn rejected_collection(collection: &RecordCollection) -> WriteError {
WriteError::invalid_request(format!(
"{collection} isn't a collection this knot takes contributed records for"
))
}
pub(super) fn no_swap_commit() -> WriteError {
WriteError::invalid_request(
"this knot compares the record version in swapRecord and never the commit in swapCommit",
)
}
pub(super) fn typed<T: DeserializeOwned>(
record: serde_json::Value,
collection: &RecordCollection,
) -> Result<T, WriteError> {
serde_json::from_value(record).map_err(|error| {
WriteError::invalid_request(format!("Record for {collection} is unusable: {error}"))
})
}
pub(super) fn kind_matches(
declared: Option<&TypeName>,
collection: &RecordCollection,
) -> Result<(), WriteError> {
match declared {
Some(declared) if declared.as_str() != collection.as_str() => {
Err(WriteError::invalid_request(format!(
"the record's $type is {declared}, and this collection is {collection}"
)))
}
_ => Ok(()),
}
}
pub(super) fn prose(title: String, body: Option<String>) -> Result<Prose, WriteError> {
let unusable = |what: &'static str| {
move |error| WriteError::invalid_request(format!("{what} is unusable: {error}"))
};
Ok(Prose::new(
RecordTicketTitle::new(title).map_err(unusable("title"))?,
body.map(MarkdownBody::new)
.transpose()
.map_err(unusable("body"))?,
))
}
#[derive(Deserialize)]
#[serde(untagged)]
pub(super) enum TicketBody {
Text(String),
Markup(MarkupInput),
}
#[derive(Deserialize)]
#[serde(untagged)]
pub(super) enum TicketTitle {
Text(String),
Inline(InlineTitle),
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct InlineTitle {
#[serde(rename = "$type", default)]
pub(super) kind: Option<String>,
pub(super) text: String,
}
impl TicketTitle {
pub(super) fn parse(self) -> Result<String, WriteError> {
match self {
Self::Text(text) => Ok(text),
Self::Inline(inline) => {
if let Some(declared) = &inline.kind
&& declared != knot_record::ticket::INLINE_MARKUP
{
return Err(WriteError::invalid_request(format!(
"a ticket's title is {}, and this one declares {declared}",
knot_record::ticket::INLINE_MARKUP
)));
}
Ok(inline.text)
}
}
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct GrantDraft {
#[serde(rename = "$type", default)]
kind: Option<TypeName>,
#[serde(rename = "createdAt", default)]
_created_at: Option<knot_types::Datetime>,
}
impl GrantDraft {
pub(super) fn parse(self, collection: &RecordCollection) -> Result<(), WriteError> {
kind_matches(self.kind.as_ref(), collection)
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct TicketDraft {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
#[serde(default)]
pub(super) repo: Option<RepoDid>,
pub(super) title: TicketTitle,
#[serde(default)]
pub(super) body: Option<TicketBody>,
#[serde(default)]
pub(super) state: Option<TicketState>,
#[serde(rename = "tangledLabels", default)]
pub(super) tangled_labels: Option<Vec<LabelToken>>,
#[serde(default)]
pub(super) assignees: Option<Vec<AccountDid>>,
#[serde(rename = "targetBranch", default)]
pub(super) target_branch: Option<TargetBranch>,
#[serde(default)]
pub(super) labels: Option<serde::de::IgnoredAny>,
#[serde(rename = "createdAt")]
pub(super) _created_at: serde::de::IgnoredAny,
#[serde(rename = "x-tngl-editor", default)]
pub(super) _editor: Option<serde::de::IgnoredAny>,
}
pub(super) struct ParsedTicket {
pub(super) prose: Prose,
pub(super) state: Option<TicketState>,
pub(super) tangled_labels: Option<Vec<LabelToken>>,
pub(super) assignees: Option<Vec<AccountDid>>,
pub(super) target_branch: Option<TargetBranch>,
pub(super) blobs: Option<Vec<knot_record::attachment::Attachment>>,
}
impl TicketDraft {
pub(super) fn parse(
self,
written_to: &RepoDid,
collection: &RecordCollection,
) -> Result<ParsedTicket, WriteError> {
kind_matches(self.kind.as_ref(), collection)?;
if self.labels.is_some() {
return Err(WriteError::invalid_request(
"labels are atproto self-labels this knot doesn't store; please leave labels out",
));
}
if let Some(echoed) = &self.repo {
on_this_knot(echoed, written_to, "record")?;
}
let title = self.title.parse()?;
let target_branch = self.target_branch;
let assignees = self.assignees.filter(|assignees| !assignees.is_empty());
if let Some(assignees) = &assignees {
if assignees.len() > MAX_ASSIGNEES {
return Err(WriteError::invalid_request(format!(
"{} assignees are over the {MAX_ASSIGNEES} limit",
assignees.len()
)));
}
if assignees.iter().collect::<BTreeSet<_>>().len() != assignees.len() {
return Err(WriteError::invalid_request("assignees lists someone twice"));
}
}
let (body, blobs) = match self.body {
Some(TicketBody::Text(text)) => (Some(text), None),
Some(TicketBody::Markup(markup)) => {
let markup = markup.parse("Ticket body's")?;
let attachments = markup.attachments();
let blobs = (!attachments.is_empty()).then(|| attachments.to_vec());
(Some(markup.text().as_str().to_owned()), blobs)
}
None => (None, None),
};
Ok(ParsedTicket {
prose: prose(title, body)?,
state: self.state,
tangled_labels: self.tangled_labels,
assignees,
target_branch,
blobs,
})
}
}
#[derive(Deserialize)]
pub(super) struct StrongRefInput {
pub(super) uri: AtUri<String>,
pub(super) cid: StrongCid,
}
#[derive(Deserialize)]
#[serde(untagged)]
pub(super) enum StrongCid {
Bare(CidString),
Linked {
#[serde(rename = "$link")]
link: CidString,
},
}
impl StrongCid {
pub(super) fn cid(self) -> CidString {
match self {
Self::Bare(cid) | Self::Linked { link: cid } => cid,
}
}
}
impl StrongRefInput {
pub(super) fn record(self, what: &'static str) -> Result<RecordCid, WriteError> {
self.cid.cid().record().ok_or_else(|| {
WriteError::invalid_request(format!(
"a record CID is dag-cbor sha2-256, and {what}'s strongRef cid isn't"
))
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct MarkupInput {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) text: String,
#[serde(default)]
pub(super) blobs: Option<Vec<knot_record::attachment::Attachment>>,
}
impl MarkupInput {
pub(super) fn parse(self, whose: &'static str) -> Result<CommentMarkup, WriteError> {
kind_matches(
self.kind.as_ref(),
&knot_record::comment::markup_collection(),
)?;
let unusable = |what: &'static str| {
move |error| WriteError::invalid_request(format!("{whose} {what} is unusable: {error}"))
};
let bare =
self.text.trim().is_empty() && self.blobs.as_ref().is_none_or(|blobs| blobs.is_empty());
if bare {
return Err(WriteError::invalid_request(format!(
"{whose} text is unusable: empty, with no attachments"
)));
}
let text = MarkdownBody::new(self.text).map_err(unusable("text"))?;
Ok(CommentMarkup::new(text, self.blobs))
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct CommentDraft {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) subject: StrongRefInput,
#[serde(rename = "replyTo", default)]
pub(super) reply_to: Option<StrongRefInput>,
pub(super) body: MarkupInput,
#[serde(default)]
pub(super) embed: Option<CommitEmbedInput>,
#[serde(rename = "createdAt")]
pub(super) _created_at: serde::de::IgnoredAny,
#[serde(rename = "x-tngl-editor", default)]
pub(super) _editor: Option<serde::de::IgnoredAny>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct CommitEmbedInput {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) repo: RepoDid,
pub(super) commit: GitOidInput,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct GitOidInput {
#[serde(rename = "$type", default)]
pub(super) _kind: Option<serde::de::IgnoredAny>,
pub(super) oid: Oid,
}
const MAX_ARTIFACT_NAME_BYTES: usize = 255;
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ArtifactDraft {
#[serde(rename = "$type", default)]
kind: Option<TypeName>,
subject: GitOidInput,
name: String,
artifact: ArtifactBlob,
#[serde(rename = "createdAt")]
_created_at: serde::de::IgnoredAny,
}
impl ArtifactDraft {
pub(super) fn parse(self) -> Result<(Oid, String, ArtifactBlob), WriteError> {
kind_matches(self.kind.as_ref(), &artifact_collection())?;
if self.name.len() > MAX_ARTIFACT_NAME_BYTES {
return Err(WriteError::invalid_request(format!(
"an artifact name is at most {MAX_ARTIFACT_NAME_BYTES} bytes, and this one is {}",
self.name.len()
)));
}
Ok((self.subject.oid, self.name, self.artifact))
}
}
pub(super) struct ParsedComment {
pub(super) subject: CommentRef,
pub(super) reply_to: Option<CommentRef>,
pub(super) embed: Option<Oid>,
pub(super) markup: CommentMarkup,
}
impl CommentDraft {
pub(super) fn parse(self, written_to: &RepoDid) -> Result<ParsedComment, WriteError> {
kind_matches(self.kind.as_ref(), &comment_collection())?;
let embed = self
.embed
.map(|embed| -> Result<Oid, WriteError> {
if let Some(kind) = embed.kind.filter(|kind| kind.as_str() != COMMIT_EMBED_TYPE) {
return Err(WriteError::invalid_request(format!(
"this knot takes {COMMIT_EMBED_TYPE} as a comment embed, and the embed's \
$type is {kind}"
)));
}
on_this_knot(&embed.repo, written_to, "embedded commit")?;
Ok(embed.commit.oid)
})
.transpose()?;
let subject = any_target(&self.subject.uri)?;
if subject.address.collection() != &ticket_collection()
&& subject.address.collection() != &patch_collection()
{
return Err(WriteError::invalid_request(format!(
"this knot takes tickets and patches as comment subjects; the uri's collection is {}",
subject.address.collection()
)));
}
on_this_knot(&subject.repo, written_to, "subject")?;
let reply_to = self
.reply_to
.map(|reply| -> Result<CommentRef, WriteError> {
let target = target(&reply.uri, &comment_collection())?;
on_this_knot(&target.repo, written_to, "reply")?;
Ok(CommentRef::new(target.address, reply.record("the reply")?))
})
.transpose()?;
Ok(ParsedComment {
subject: CommentRef::new(subject.address, self.subject.record("the subject")?),
reply_to,
embed,
markup: self.body.parse("Comment body's")?,
})
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ReactionDraft {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) subject: StrongRefInput,
pub(super) reaction: ReactionKind,
#[serde(rename = "createdAt")]
pub(super) _created_at: serde::de::IgnoredAny,
}
impl ReactionDraft {
pub(super) fn parse(
self,
written_to: &RepoDid,
) -> Result<(CommentRef, ReactionKind), WriteError> {
kind_matches(self.kind.as_ref(), &reaction_collection())?;
let subject = any_target(&self.subject.uri)?;
on_this_knot(&subject.repo, written_to, "subject")?;
Ok((
CommentRef::new(subject.address, self.subject.record("the subject")?),
self.reaction,
))
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct LabelDefDraft {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) name: LabelName,
#[serde(default)]
pub(super) color: Option<LabelColor>,
#[serde(rename = "createdAt")]
pub(super) _created_at: serde::de::IgnoredAny,
#[serde(rename = "x-tngl-editor", default)]
pub(super) _editor: Option<serde::de::IgnoredAny>,
}
impl LabelDefDraft {
pub(super) fn parse(self, collection: &RecordCollection) -> Result<RecordLabelDef, WriteError> {
kind_matches(self.kind.as_ref(), collection)?;
Ok(RecordLabelDef::new(self.name, self.color))
}
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct OidDraft {
pub(super) oid: Oid,
}
pub(super) fn on_this_knot(
repo: &RepoDid,
written_to: &RepoDid,
what: &'static str,
) -> Result<(), WriteError> {
if repo == written_to {
return Ok(());
}
Err(WriteError::invalid_request(format!(
"the write is for {written_to}, but the {what}'s repo is {repo}"
)))
}
pub(super) struct Target {
pub(super) repo: RepoDid,
pub(super) address: RecordAddress,
}
pub(super) fn target(
uri: &AtUri<String>,
collection: &RecordCollection,
) -> Result<Target, WriteError> {
let target = any_target(uri)?;
if target.address.collection() != collection {
return Err(WriteError::invalid_request(format!(
"this procedure takes {collection}; the uri's collection is {}",
target.address.collection()
)));
}
Ok(target)
}
pub(super) fn any_target(uri: &AtUri<String>) -> Result<Target, WriteError> {
let (repo, address) = RecordAddress::parse_at_uri(uri)
.map_err(|error| WriteError::invalid_request(error.to_string()))?;
Ok(Target { repo, address })
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ManifestDeclaration {
pub(super) owner: OwnerDid,
pub(super) name: RepoName,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct ManifestDraft {
#[serde(rename = "$type", default)]
pub(super) kind: Option<TypeName>,
pub(super) declaration: ManifestDeclaration,
#[serde(default)]
pub(super) spindle: Option<ServiceDid>,
#[serde(default)]
pub(super) description: Option<RepoDescription>,
#[serde(default)]
pub(super) website: Option<url::Url>,
#[serde(default)]
pub(super) topics: Option<RepoTopics>,
#[serde(default)]
pub(super) source: Option<ManifestSource>,
#[serde(rename = "contributionPolicy", default)]
pub(super) contribution_policy: Option<ContributionPolicy>,
#[serde(rename = "createdAt")]
pub(super) _created_at: serde::de::IgnoredAny,
#[serde(rename = "x-tngl-editor", default)]
pub(super) _editor: Option<serde::de::IgnoredAny>,
}
/// unknown fields are fine, since a newer knot may have written the manifest.
#[derive(Deserialize)]
pub(crate) struct StoredManifest {
pub(crate) declaration: StoredDeclaration,
#[serde(rename = "contributionPolicy", default)]
pub(crate) contribution_policy: Option<ContributionPolicy>,
}
#[derive(Deserialize)]
pub(crate) struct StoredDeclaration {
pub(crate) owner: OwnerDid,
pub(crate) name: RepoName,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
pub(super) struct SubmitPatchInput {
pub(super) repo: RepoDid,
pub(super) patch: SubmitPatchDraft,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
pub(super) struct SubmitPatchDraft {
#[serde(deserialize_with = "record_key")]
pub(super) ticket: RecordRkey,
pub(super) base: OidDraft,
pub(super) head: OidDraft,
pub(super) source_repo: RepoDid,
#[serde(default)]
pub(super) source_branch: Option<BranchName>,
}