Migrate DAV props to serde. Big clusterfuck right now but it'll hopefully pay off

This commit is contained in:
Lennart
2024-03-14 19:24:23 +01:00
parent a145445277
commit b540362791
10 changed files with 246 additions and 190 deletions

View File

@@ -17,7 +17,6 @@ use tokio::sync::RwLock;
pub mod error; pub mod error;
pub mod resources; pub mod resources;
pub mod routes; pub mod routes;
pub mod tagname;
pub struct CalDavContext<C: CalendarStore + ?Sized> { pub struct CalDavContext<C: CalendarStore + ?Sized> {
pub prefix: String, pub prefix: String,

View File

@@ -1,19 +1,16 @@
use std::{io::Write, sync::Arc};
use actix_web::{web::Data, HttpRequest}; use actix_web::{web::Data, HttpRequest};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use quick_xml::Writer;
use rustical_auth::AuthInfo; use rustical_auth::AuthInfo;
use rustical_store::calendar::{Calendar, CalendarStore};
use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames};
use tokio::sync::RwLock;
use crate::tagname::TagName;
use rustical_dav::{ use rustical_dav::{
resource::Resource, resource::Resource,
xml_snippets::{write_resourcetype, HrefElement, TextElement}, xml_snippets::{HrefElement, TextNode},
}; };
use rustical_store::calendar::{Calendar, CalendarStore};
use serde::Serialize;
use std::sync::Arc;
use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames};
use tokio::sync::RwLock;
pub struct CalendarResource<C: CalendarStore + ?Sized> { pub struct CalendarResource<C: CalendarStore + ?Sized> {
pub cal_store: Arc<RwLock<C>>, pub cal_store: Arc<RwLock<C>>,
@@ -23,7 +20,98 @@ pub struct CalendarResource<C: CalendarStore + ?Sized> {
pub principal: String, pub principal: String,
} }
#[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty)] #[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SupportedCalendarComponent(#[serde(rename = "@name")] pub &'static str);
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct SupportedCalendarComponentSet {
#[serde(rename = "C:comp")]
pub comp: Vec<SupportedCalendarComponent>,
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct CalendarData {
content_type: &'static str,
version: &'static str,
}
impl Default for CalendarData {
fn default() -> Self {
Self {
content_type: "text/calendar;charset=utf-8",
version: "2.0",
}
}
}
#[derive(Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct SupportedCalendarData {
#[serde(rename = "C:calendar-data")]
calendar_data: CalendarData,
}
#[derive(Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Resourcetype {
#[serde(rename = "C:calendar")]
calendar: (),
collection: (),
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum UserPrivilege {
Read,
ReadAcl,
Write,
WriteAcl,
WriteContent,
ReadCurrentUserPrivilegeSet,
Bind,
Unbind,
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct UserPrivilegeWrapper {
#[serde(rename = "$value")]
privilege: UserPrivilege,
}
impl From<UserPrivilege> for UserPrivilegeWrapper {
fn from(value: UserPrivilege) -> Self {
Self { privilege: value }
}
}
#[derive(Serialize)]
#[serde(rename_all = "kebab-case")]
pub struct UserPrivilegeSet {
privilege: Vec<UserPrivilegeWrapper>,
}
impl Default for UserPrivilegeSet {
fn default() -> Self {
Self {
privilege: vec![
UserPrivilege::Read.into(),
UserPrivilege::ReadAcl.into(),
UserPrivilege::Write.into(),
UserPrivilege::WriteAcl.into(),
UserPrivilege::WriteContent.into(),
UserPrivilege::ReadCurrentUserPrivilegeSet.into(),
UserPrivilege::Bind.into(),
UserPrivilege::Unbind.into(),
],
}
}
}
#[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty, Clone)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
pub enum CalendarProp { pub enum CalendarProp {
Resourcetype, Resourcetype,
@@ -43,11 +131,27 @@ pub enum CalendarProp {
MaxResourceSize, MaxResourceSize,
} }
#[derive(Serialize)]
#[serde(untagged)]
pub enum CalendarPropResponse {
Resourcetype(Resourcetype),
CurrentUser(HrefElement),
Displayname(TextNode),
CalendarColor(TextNode),
CalendarDescription(TextNode),
SupportedCalendarComponentSet(SupportedCalendarComponentSet),
SupportedCalendarData(SupportedCalendarData),
Getcontenttype(TextNode),
MaxResourceSize(TextNode),
CurrentUserPrivilegeSet(UserPrivilegeSet),
}
#[async_trait(?Send)] #[async_trait(?Send)]
impl<C: CalendarStore + ?Sized> Resource for CalendarResource<C> { impl<C: CalendarStore + ?Sized> Resource for CalendarResource<C> {
type MemberType = Self; type MemberType = Self;
type UriComponents = (String, String); // principal, calendar_id type UriComponents = (String, String); // principal, calendar_id
type PropType = CalendarProp; type PropType = CalendarProp;
type PropResponse = CalendarPropResponse;
async fn acquire_from_request( async fn acquire_from_request(
req: HttpRequest, req: HttpRequest,
@@ -81,93 +185,44 @@ impl<C: CalendarStore + ?Sized> Resource for CalendarResource<C> {
Ok(vec![]) Ok(vec![])
} }
fn write_prop<W: Write>(&self, writer: &mut Writer<W>, prop: Self::PropType) -> Result<()> { fn get_prop(&self, prop: Self::PropType) -> Result<Self::PropResponse> {
match prop { match prop {
CalendarProp::Resourcetype => { CalendarProp::Resourcetype => {
write_resourcetype(writer, vec!["C:calendar", "collection"])? Ok(CalendarPropResponse::Resourcetype(Resourcetype::default()))
} }
CalendarProp::CurrentUserPrincipal | CalendarProp::Owner => { CalendarProp::CurrentUserPrincipal | CalendarProp::Owner => {
writer.write_serializable( Ok(CalendarPropResponse::CurrentUser(HrefElement::new(
prop.tagname(), format!("{}/{}/", self.prefix, self.principal),
&HrefElement::new(format!("{}/{}/", self.prefix, self.principal)), )))
)?;
}
CalendarProp::Displayname => {
let name = self.calendar.name.clone();
writer.write_serializable(prop.tagname(), &TextElement(name))?;
}
CalendarProp::CalendarColor => {
let color = self.calendar.color.clone();
writer.write_serializable(prop.tagname(), &TextElement(color))?;
}
CalendarProp::CalendarDescription => {
let description = self.calendar.description.clone();
writer.write_serializable(prop.tagname(), &TextElement(description))?;
} }
CalendarProp::Displayname => Ok(CalendarPropResponse::Displayname(TextNode(
self.calendar.name.clone(),
))),
CalendarProp::CalendarColor => Ok(CalendarPropResponse::CalendarColor(TextNode(
self.calendar.color.clone(),
))),
CalendarProp::CalendarDescription => Ok(CalendarPropResponse::CalendarDescription(
TextNode(self.calendar.description.clone()),
)),
CalendarProp::SupportedCalendarComponentSet => { CalendarProp::SupportedCalendarComponentSet => {
writer Ok(CalendarPropResponse::SupportedCalendarComponentSet(
.create_element(prop.tagname()) SupportedCalendarComponentSet {
.write_inner_content(|writer| { comp: vec![SupportedCalendarComponent("VEVENT")],
writer },
.create_element("C:comp") ))
.with_attribute(("name", "VEVENT"))
.write_empty()?;
Ok::<(), quick_xml::Error>(())
})?;
} }
CalendarProp::SupportedCalendarData => { CalendarProp::SupportedCalendarData => Ok(CalendarPropResponse::SupportedCalendarData(
writer SupportedCalendarData::default(),
.create_element(prop.tagname()) )),
.write_inner_content(|writer| { CalendarProp::Getcontenttype => Ok(CalendarPropResponse::Getcontenttype(TextNode(
// <cal:calendar-data content-type="text/calendar" version="2.0" /> Some("text/calendar;charset=utf-8".to_owned()),
writer ))),
.create_element("C:calendar-data") CalendarProp::MaxResourceSize => Ok(CalendarPropResponse::MaxResourceSize(TextNode(
.with_attributes(vec![ Some("10000000".to_owned()),
("content-type", "text/calendar"), ))),
("version", "2.0"), CalendarProp::CurrentUserPrivilegeSet => Ok(
]) CalendarPropResponse::CurrentUserPrivilegeSet(UserPrivilegeSet::default()),
.write_empty()?; ),
Ok::<(), quick_xml::Error>(()) }
})?;
}
CalendarProp::Getcontenttype => {
writer.write_serializable(
prop.tagname(),
&TextElement(Some("text/calendar".to_owned())),
)?;
}
CalendarProp::MaxResourceSize => {
writer.write_serializable(
prop.tagname(),
&TextElement(Some("10000000".to_owned())),
)?;
}
CalendarProp::CurrentUserPrivilegeSet => {
writer
.create_element(prop.tagname())
// These are just hard-coded for now and will possibly change in the future
.write_inner_content(|writer| {
for privilege in [
"read",
"read-acl",
"write",
"write-acl",
"write-content",
"read-current-user-privilege-set",
"bind",
"unbind",
] {
writer
.create_element("privilege")
.write_inner_content(|writer| {
writer.create_element(privilege).write_empty()?;
Ok::<(), quick_xml::Error>(())
})?;
}
Ok::<(), quick_xml::Error>(())
})?;
}
};
Ok(())
} }
} }

View File

@@ -1,11 +1,11 @@
use crate::tagname::TagName;
use actix_web::{web::Data, HttpRequest}; use actix_web::{web::Data, HttpRequest};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use rustical_auth::AuthInfo; use rustical_auth::AuthInfo;
use rustical_dav::{resource::Resource, xml_snippets::TextElement}; use rustical_dav::{resource::Resource, xml_snippets::TextNode};
use rustical_store::calendar::CalendarStore; use rustical_store::calendar::CalendarStore;
use rustical_store::event::Event; use rustical_store::event::Event;
use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames}; use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames};
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -16,7 +16,7 @@ pub struct EventResource<C: CalendarStore + ?Sized> {
pub event: Event, pub event: Event,
} }
#[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty)] #[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty, Clone)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
pub enum EventProp { pub enum EventProp {
Getetag, Getetag,
@@ -25,11 +25,20 @@ pub enum EventProp {
Getcontenttype, Getcontenttype,
} }
#[derive(Serialize)]
#[serde(untagged)]
pub enum PrincipalPropResponse {
Getetag(TextNode),
CalendarData(TextNode),
Getcontenttype(TextNode),
}
#[async_trait(?Send)] #[async_trait(?Send)]
impl<C: CalendarStore + ?Sized> Resource for EventResource<C> { impl<C: CalendarStore + ?Sized> Resource for EventResource<C> {
type UriComponents = (String, String, String); // principal, calendar, event type UriComponents = (String, String, String); // principal, calendar, event
type MemberType = Self; type MemberType = Self;
type PropType = EventProp; type PropType = EventProp;
type PropResponse = PrincipalPropResponse;
fn get_path(&self) -> &str { fn get_path(&self) -> &str {
&self.path &self.path
@@ -62,29 +71,17 @@ impl<C: CalendarStore + ?Sized> Resource for EventResource<C> {
}) })
} }
fn write_prop<W: std::io::Write>( fn get_prop(&self, prop: Self::PropType) -> Result<Self::PropResponse> {
&self,
writer: &mut quick_xml::Writer<W>,
prop: Self::PropType,
) -> Result<()> {
match prop { match prop {
EventProp::Getetag => { EventProp::Getetag => Ok(PrincipalPropResponse::Getetag(TextNode(Some(
writer.write_serializable( self.event.get_etag(),
prop.tagname(), )))),
&TextElement(Some(self.event.get_etag())), EventProp::CalendarData => Ok(PrincipalPropResponse::CalendarData(TextNode(Some(
)?; self.event.get_ics(),
} )))),
EventProp::CalendarData => { EventProp::Getcontenttype => Ok(PrincipalPropResponse::Getcontenttype(TextNode(Some(
writer "text/calendar;charset=utf-8".to_owned(),
.write_serializable(prop.tagname(), &TextElement(Some(self.event.get_ics())))?; )))),
} }
EventProp::Getcontenttype => {
writer.write_serializable(
prop.tagname(),
&TextElement(Some("text/calendar;charset=utf-8".to_owned())),
)?;
}
};
Ok(())
} }
} }

View File

@@ -1,13 +1,10 @@
use crate::tagname::TagName;
use actix_web::{web::Data, HttpRequest}; use actix_web::{web::Data, HttpRequest};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use rustical_auth::AuthInfo; use rustical_auth::AuthInfo;
use rustical_dav::{ use rustical_dav::{resource::Resource, xml_snippets::HrefElement};
resource::Resource,
xml_snippets::{write_resourcetype, HrefElement},
};
use rustical_store::calendar::CalendarStore; use rustical_store::calendar::CalendarStore;
use serde::Serialize;
use std::sync::Arc; use std::sync::Arc;
use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames}; use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames};
use tokio::sync::RwLock; use tokio::sync::RwLock;
@@ -21,7 +18,21 @@ pub struct PrincipalCalendarsResource<C: CalendarStore + ?Sized> {
cal_store: Arc<RwLock<C>>, cal_store: Arc<RwLock<C>>,
} }
#[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty)] #[derive(Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Resourcetype {
principal: (),
collection: (),
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum PrincipalPropResponse {
Resourcetype(Resourcetype),
CurrentUser(HrefElement),
}
#[derive(EnumString, Debug, VariantNames, IntoStaticStr, EnumProperty, Clone)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
pub enum PrincipalProp { pub enum PrincipalProp {
Resourcetype, Resourcetype,
@@ -39,6 +50,7 @@ impl<C: CalendarStore + ?Sized> Resource for PrincipalCalendarsResource<C> {
type UriComponents = (); type UriComponents = ();
type MemberType = CalendarResource<C>; type MemberType = CalendarResource<C>;
type PropType = PrincipalProp; type PropType = PrincipalProp;
type PropResponse = PrincipalPropResponse;
fn get_path(&self) -> &str { fn get_path(&self) -> &str {
&self.path &self.path
@@ -83,26 +95,17 @@ impl<C: CalendarStore + ?Sized> Resource for PrincipalCalendarsResource<C> {
path: req.path().to_string(), path: req.path().to_string(),
}) })
} }
fn get_prop(&self, prop: Self::PropType) -> Result<Self::PropResponse> {
fn write_prop<W: std::io::Write>(
&self,
writer: &mut quick_xml::Writer<W>,
prop: Self::PropType,
) -> Result<()> {
match prop { match prop {
PrincipalProp::Resourcetype => { PrincipalProp::Resourcetype => {
write_resourcetype(writer, vec!["principal", "collection"])? Ok(PrincipalPropResponse::Resourcetype(Resourcetype::default()))
} }
PrincipalProp::CurrentUserPrincipal PrincipalProp::CurrentUserPrincipal
| PrincipalProp::PrincipalUrl | PrincipalProp::PrincipalUrl
| PrincipalProp::CalendarHomeSet | PrincipalProp::CalendarHomeSet
| PrincipalProp::CalendarUserAddressSet => { | PrincipalProp::CalendarUserAddressSet => Ok(PrincipalPropResponse::CurrentUser(
writer.write_serializable( HrefElement::new(format!("{}/{}/", self.prefix, self.principal)),
prop.tagname(), )),
&HrefElement::new(format!("{}/{}/", self.prefix, self.principal)), }
)?;
}
};
Ok(())
} }
} }

View File

@@ -1,10 +1,9 @@
use crate::tagname::TagName;
use actix_web::HttpRequest; use actix_web::HttpRequest;
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use quick_xml::events::BytesText;
use rustical_auth::AuthInfo; use rustical_auth::AuthInfo;
use rustical_dav::{resource::Resource, xml_snippets::write_resourcetype}; use rustical_dav::{resource::Resource, xml_snippets::HrefElement};
use serde::Serialize;
use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames}; use strum::{EnumProperty, EnumString, IntoStaticStr, VariantNames};
pub struct RootResource { pub struct RootResource {
@@ -13,18 +12,32 @@ pub struct RootResource {
path: String, path: String,
} }
#[derive(EnumString, Debug, VariantNames, EnumProperty, IntoStaticStr)] #[derive(EnumString, Debug, VariantNames, EnumProperty, IntoStaticStr, Clone)]
#[strum(serialize_all = "kebab-case")] #[strum(serialize_all = "kebab-case")]
pub enum RootProp { pub enum RootProp {
Resourcetype, Resourcetype,
CurrentUserPrincipal, CurrentUserPrincipal,
} }
#[derive(Serialize, Default)]
#[serde(rename_all = "kebab-case")]
pub struct Resourcetype {
collection: (),
}
#[derive(Serialize)]
#[serde(untagged)]
pub enum RootPropResponse {
Resourcetype(Resourcetype),
CurrentUser(HrefElement),
}
#[async_trait(?Send)] #[async_trait(?Send)]
impl Resource for RootResource { impl Resource for RootResource {
type UriComponents = (); type UriComponents = ();
type MemberType = Self; type MemberType = Self;
type PropType = RootProp; type PropType = RootProp;
type PropResponse = RootPropResponse;
fn get_path(&self) -> &str { fn get_path(&self) -> &str {
&self.path &self.path
@@ -47,27 +60,12 @@ impl Resource for RootResource {
}) })
} }
fn write_prop<W: std::io::Write>( fn get_prop(&self, prop: Self::PropType) -> Result<Self::PropResponse> {
&self,
writer: &mut quick_xml::Writer<W>,
prop: Self::PropType,
) -> Result<()> {
match prop { match prop {
RootProp::Resourcetype => write_resourcetype(writer, vec!["collection"])?, RootProp::Resourcetype => Ok(RootPropResponse::Resourcetype(Resourcetype::default())),
RootProp::CurrentUserPrincipal => { RootProp::CurrentUserPrincipal => Ok(RootPropResponse::CurrentUser(HrefElement::new(
writer format!("{}/{}/", self.prefix, self.principal),
.create_element(prop.tagname()) ))),
.write_inner_content(|writer| { }
writer
.create_element("href")
.write_text_content(BytesText::new(&format!(
"{}/{}",
self.prefix, self.principal
)))?;
Ok::<(), quick_xml::Error>(())
})?;
}
};
Ok(())
} }
} }

View File

@@ -11,4 +11,5 @@ derive_more = "0.99"
futures-util = "0.3" futures-util = "0.3"
quick-xml = "0.31" quick-xml = "0.31"
rustical_auth = { path = "../auth/" } rustical_auth = { path = "../auth/" }
serde = { version = "1.0.197", features = ["derive"] }
strum = "0.26.2" strum = "0.26.2"

View File

@@ -1,4 +1,5 @@
pub mod depth_extractor; pub mod depth_extractor;
pub mod namespace; pub mod namespace;
pub mod resource; pub mod resource;
pub mod tagname;
pub mod xml_snippets; pub mod xml_snippets;

View File

@@ -1,13 +1,16 @@
use std::{io::Write, str::FromStr};
use actix_web::{http::StatusCode, HttpRequest}; use actix_web::{http::StatusCode, HttpRequest};
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use quick_xml::Writer; use quick_xml::Writer;
use rustical_auth::AuthInfo; use rustical_auth::AuthInfo;
use strum::VariantNames; use serde::Serialize;
use std::str::FromStr;
use strum::{EnumProperty, VariantNames};
use crate::xml_snippets::{write_invalid_props_response, write_propstat_response}; use crate::{
tagname::TagName,
xml_snippets::{write_invalid_props_response, write_propstat_response},
};
// A resource is identified by a URI and has properties // A resource is identified by a URI and has properties
// A resource can also be a collection // A resource can also be a collection
@@ -17,7 +20,8 @@ use crate::xml_snippets::{write_invalid_props_response, write_propstat_response}
pub trait Resource: Sized { pub trait Resource: Sized {
type MemberType: Resource; type MemberType: Resource;
type UriComponents: Sized; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String) type UriComponents: Sized; // defines how the resource URI maps to parameters, i.e. /{principal}/{calendar} -> (String, String)
type PropType: FromStr + VariantNames; type PropType: FromStr + VariantNames + Into<&'static str> + EnumProperty + Clone;
type PropResponse: Serialize;
async fn acquire_from_request( async fn acquire_from_request(
req: HttpRequest, req: HttpRequest,
@@ -32,7 +36,7 @@ pub trait Resource: Sized {
fn list_dead_props() -> &'static [&'static str] { fn list_dead_props() -> &'static [&'static str] {
Self::PropType::VARIANTS Self::PropType::VARIANTS
} }
fn write_prop<W: Write>(&self, writer: &mut Writer<W>, prop: Self::PropType) -> Result<()>; fn get_prop(&self, prop: Self::PropType) -> Result<Self::PropResponse>;
} }
pub trait HandlePropfind { pub trait HandlePropfind {
@@ -59,13 +63,12 @@ impl<R: Resource> HandlePropfind for R {
for prop in props { for prop in props {
if let Ok(valid_prop) = R::PropType::from_str(prop) { if let Ok(valid_prop) = R::PropType::from_str(prop) {
// TODO: Fix error types // TODO: Fix error types
match self match self.get_prop(valid_prop.clone()) {
.write_prop(writer, valid_prop) Ok(response) => {
.map_err(|_e| quick_xml::Error::TextNotFound) writer
{ .write_serializable(valid_prop.tagname(), &response)
// TODO: clean this mess up .map_err(|_e| quick_xml::Error::TextNotFound)?;
Ok(_) => {} }
// not really an invalid prop, but some error happened
Err(_) => invalid_props.push(prop), Err(_) => invalid_props.push(prop),
}; };
} else { } else {

View File

@@ -6,21 +6,20 @@ use quick_xml::{
events::{attributes::Attribute, BytesText}, events::{attributes::Attribute, BytesText},
Writer, Writer,
}; };
use serde::Serialize;
pub fn write_resourcetype<W: Write>( #[derive(Serialize)]
writer: &mut Writer<W>, pub struct HrefElement {
types: Vec<&str>, pub href: String,
) -> Result<(), quick_xml::Error> {
writer
.create_element("resourcetype")
.write_inner_content(|writer| {
for resourcetype in types {
writer.create_element(resourcetype).write_empty()?;
}
Ok::<(), quick_xml::Error>(())
})?;
Ok(())
} }
impl HrefElement {
pub fn new(href: String) -> Self {
Self { href }
}
}
#[derive(Serialize)]
pub struct TextNode(pub Option<String>);
pub fn write_invalid_props_response<W: Write>( pub fn write_invalid_props_response<W: Write>(
writer: &mut Writer<W>, writer: &mut Writer<W>,