Skip to content

Commit

Permalink
Add specta-zod
Browse files Browse the repository at this point in the history
This was moved from mattrax/Mattrax repository and was originally written by Brendan.

Co-authored-by: Brendan Allan <[email protected]>
  • Loading branch information
oscartbeaumont and Brendonovich committed Jun 12, 2024
1 parent fadef54 commit d1bfae8
Show file tree
Hide file tree
Showing 6 changed files with 1,770 additions and 0 deletions.
15 changes: 15 additions & 0 deletions crates/specta-zod/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[package]
name = "specta-zod"
description = "Export your Rust types to Zod schemas for TypeScript"
version = "0.0.1"
authors = ["Oscar Beaumont <[email protected]>"]
edition = "2021"
license = "MIT"
repository = "https://github.com/oscartbeaumont/specta"
documentation = "https://docs.rs/specta-zod/latest/specta-zod"
keywords = ["async", "specta", "rspc", "typescript", "typesafe"]
categories = ["web-programming", "asynchronous"]

[dependencies]
specta = { path = "../../", features = ["typescript"] }
thiserror = "1.0.60"
87 changes: 87 additions & 0 deletions crates/specta-zod/src/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
use std::{borrow::Cow, fmt};

use crate::{export_config::ExportConfig, ImplLocation};

#[derive(Clone, Debug)]
pub(crate) enum PathItem {
Type(Cow<'static, str>),
TypeExtended(Cow<'static, str>, ImplLocation),
Field(Cow<'static, str>),
Variant(Cow<'static, str>),
}

#[derive(Clone)]
pub(crate) struct ExportContext<'a> {
pub(crate) cfg: &'a ExportConfig,
pub(crate) path: Vec<PathItem>,
// `false` when inline'ing and `true` when exporting as named.
pub(crate) is_export: bool,
}

impl ExportContext<'_> {
pub(crate) fn with(&self, item: PathItem) -> Self {
Self {
path: self.path.iter().cloned().chain([item]).collect(),
..*self
}
}

pub(crate) fn export_path(&self) -> ExportPath {
ExportPath::new(&self.path)
}
}

/// Represents the path of an error in the export tree.
/// This is designed to be opaque, meaning it's internal format and `Display` impl are subject to change at will.
pub struct ExportPath(String);

impl ExportPath {
pub(crate) fn new(path: &[PathItem]) -> Self {
let mut s = String::new();
let mut path = path.iter().peekable();
while let Some(item) = path.next() {
s.push_str(match item {
PathItem::Type(v) => v,
PathItem::TypeExtended(_, loc) => loc.as_str(),
PathItem::Field(v) => v,
PathItem::Variant(v) => v,
});

if let Some(next) = path.peek() {
s.push_str(match next {
PathItem::Type(_) => " -> ",
PathItem::TypeExtended(_, _) => " -> ",
PathItem::Field(_) => ".",
PathItem::Variant(_) => "::",
});
} else {
break;
}
}

Self(s)
}

#[doc(hidden)]
pub fn new_unsafe(path: &str) -> Self {
Self(path.to_string())
}
}

impl PartialEq for ExportPath {
fn eq(&self, other: &Self) -> bool {
self.0 == other.0
}
}

impl fmt::Debug for ExportPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}

impl fmt::Display for ExportPath {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
78 changes: 78 additions & 0 deletions crates/specta-zod/src/error.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
use core::fmt;
use std::borrow::Cow;

use thiserror::Error;

use crate::{context::ExportPath, ImplLocation, SerdeError};

/// Describes where an error occurred.
#[derive(Error, Debug, PartialEq)]
pub enum NamedLocation {
Type,
Field,
Variant,
}

impl fmt::Display for NamedLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Type => write!(f, "type"),
Self::Field => write!(f, "field"),
Self::Variant => write!(f, "variant"),
}
}
}

/// The error type for the TypeScript exporter.
#[derive(Error, Debug)]
#[non_exhaustive]
pub enum ExportError {
#[error("Attempted to export '{0}' but Specta configuration forbids exporting BigInt types (i64, u64, i128, u128) because we don't know if your se/deserializer supports it. You can change this behavior by editing your `ExportConfiguration`!")]
BigIntForbidden(ExportPath),
#[error("Serde error: {0}")]
Serde(#[from] SerdeError),
// #[error("Attempted to export '{0}' but was unable to export a tagged type which is unnamed")]
// UnableToTagUnnamedType(ExportPath),
#[error("Attempted to export '{1}' but was unable to due to {0} name '{2}' conflicting with a reserved keyword in Typescript. Try renaming it or using `#[specta(rename = \"new name\")]`")]
ForbiddenName(NamedLocation, ExportPath, &'static str),
#[error("Attempted to export '{1}' but was unable to due to {0} name '{2}' containing an invalid character")]
InvalidName(NamedLocation, ExportPath, String),
#[error("Attempted to export '{0}' with tagging but the type is not tagged.")]
InvalidTagging(ExportPath),
#[error("Attempted to export '{0}' with internal tagging but the variant is a tuple struct.")]
InvalidTaggedVariantContainingTupleStruct(ExportPath),
#[error("Unable to export type named '{0}' from locations '{:?}' '{:?}'", .1.as_str(), .2.as_str())]
DuplicateTypeName(Cow<'static, str>, ImplLocation, ImplLocation),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Failed to export '{0}' due to error: {1}")]
Other(ExportPath, String),
}

// TODO: This `impl` is cringe
impl PartialEq for ExportError {
fn eq(&self, other: &Self) -> bool {
match (self, other) {
(Self::BigIntForbidden(l0), Self::BigIntForbidden(r0)) => l0 == r0,
(Self::Serde(l0), Self::Serde(r0)) => l0 == r0,
// (Self::UnableToTagUnnamedType(l0), Self::UnableToTagUnnamedType(r0)) => l0 == r0,
(Self::ForbiddenName(l0, l1, l2), Self::ForbiddenName(r0, r1, r2)) => {
l0 == r0 && l1 == r1 && l2 == r2
}
(Self::InvalidName(l0, l1, l2), Self::InvalidName(r0, r1, r2)) => {
l0 == r0 && l1 == r1 && l2 == r2
}
(Self::InvalidTagging(l0), Self::InvalidTagging(r0)) => l0 == r0,
(
Self::InvalidTaggedVariantContainingTupleStruct(l0),
Self::InvalidTaggedVariantContainingTupleStruct(r0),
) => l0 == r0,
(Self::DuplicateTypeName(l0, l1, l2), Self::DuplicateTypeName(r0, r1, r2)) => {
l0 == r0 && l1 == r1 && l2 == r2
}
(Self::Io(l0), Self::Io(r0)) => l0.to_string() == r0.to_string(), // This is a bit hacky but it will be fine for usage in unit tests!
(Self::Other(l0, l1), Self::Other(r0, r1)) => l0 == r0 && l1 == r1,
_ => false,
}
}
}
110 changes: 110 additions & 0 deletions crates/specta-zod/src/export_config.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
use std::{borrow::Cow, io, path::PathBuf};

use specta::ts::{comments, CommentFormatterFn};

use crate::DeprecatedType;

#[derive(Debug)]
#[non_exhaustive]
pub struct CommentFormatterArgs<'a> {
pub docs: &'a Cow<'static, str>,
pub deprecated: Option<&'a DeprecatedType>,
}

/// The signature for a function responsible for exporting Typescript comments.
// pub type CommentFormatterFn = fn(CommentFormatterArgs) -> String; // TODO: Returning `Cow`???

/// The signature for a function responsible for formatter a Typescript file.
pub type FormatterFn = fn(PathBuf) -> io::Result<()>;

/// Options for controlling the behavior of the Typescript exporter.
#[derive(Debug, Clone)]
pub struct ExportConfig {
/// How BigInts should be exported.
pub(crate) bigint: BigIntExportBehavior,
/// How comments should be rendered.
pub(crate) comment_exporter: Option<CommentFormatterFn>,
/// How the resulting file should be formatted.
pub(crate) formatter: Option<FormatterFn>,
}

impl ExportConfig {
/// Construct a new `ExportConfiguration`
pub fn new() -> Self {
Default::default()
}

/// Configure the BigInt handling behaviour
pub fn bigint(mut self, bigint: BigIntExportBehavior) -> Self {
self.bigint = bigint;
self
}

/// Configure a function which is responsible for styling the comments to be exported
///
/// Implementations:
/// - [`js_doc`](crate::lang::ts::js_doc)
///
/// Not calling this method will default to the [`js_doc`](crate::lang::ts::js_doc) exporter.
/// `None` will disable comment exporting.
/// `Some(exporter)` will enable comment exporting using the provided exporter.
pub fn comment_style(mut self, exporter: Option<CommentFormatterFn>) -> Self {
self.comment_exporter = exporter;
self
}

/// Configure a function which is responsible for formatting the result file or files
///
///
/// Implementations:
/// - [`prettier`](crate::lang::ts::prettier)
/// - [`ESLint`](crate::lang::ts::eslint)
pub fn formatter(mut self, formatter: FormatterFn) -> Self {
self.formatter = Some(formatter);
self
}

/// Run the specified formatter on the given path.
pub fn run_format(&self, path: PathBuf) -> io::Result<()> {
if let Some(formatter) = self.formatter {
formatter(path)?;
}
Ok(())
}
}

impl Default for ExportConfig {
fn default() -> Self {
Self {
bigint: Default::default(),
comment_exporter: Some(comments::js_doc),
formatter: None,
}
}
}

/// Allows you to configure how Specta's Typescript exporter will deal with BigInt types ([i64], [i128] etc).
///
/// WARNING: None of these settings affect how your data is actually ser/deserialized.
/// It's up to you to adjust your ser/deserialize settings.
#[derive(Debug, Clone, Default)]
pub enum BigIntExportBehavior {
/// Export BigInt as a Typescript `string`
///
/// Doing this is serde is [pretty simple](https://github.com/serde-rs/json/issues/329#issuecomment-305608405).
String,
/// Export BigInt as a Typescript `number`.
///
/// WARNING: `JSON.parse` in JS will truncate your number resulting in data loss so ensure your deserializer supports large numbers.
Number,
/// Export BigInt as a Typescript `BigInt`.
BigInt,
/// Abort the export with an error.
///
/// This is the default behavior because without integration from your serializer and deserializer we can't guarantee data loss won't occur.
#[default]
Fail,
/// Same as `Self::Fail` but it allows a library to configure the message shown to the end user.
#[doc(hidden)]
FailWithReason(&'static str),
}
Loading

0 comments on commit d1bfae8

Please sign in to comment.