use std::{collections::HashSet, fmt::Display};
use itertools::Itertools;
use miette::Diagnostic;
use nonempty::NonEmpty;
use smol_str::{format_smolstr, SmolStr};
use thiserror::Error;
use crate::ast::{is_normalized_ident, InternalName};
use crate::validator::{json_schema, RawName};
pub const NUM_INDENTATION_SPACES: usize = 2;
struct BaseIndentation(String);
impl BaseIndentation {
fn none() -> Self {
BaseIndentation(String::new())
}
fn next(&self) -> Self {
BaseIndentation(" ".repeat(self.len() + NUM_INDENTATION_SPACES))
}
fn len(&self) -> usize {
self.0.len()
}
}
impl Display for BaseIndentation {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
trait IndentedDisplay {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result;
}
struct Indented<'a, T: IndentedDisplay>(&'a T, &'a BaseIndentation);
impl<T: IndentedDisplay> Display for Indented<'_, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.0.fmt_indented(f, self.1)
}
}
impl<N: Display> Display for json_schema::Fragment<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
for (i, (ns, def)) in self.0.iter().enumerate() {
match ns {
None => def.fmt(f)?,
Some(ns) => writeln!(
f,
"{}namespace {ns} {{\n{}}}",
def.annotations,
Indented(def, &BaseIndentation::none().next())
)?,
}
if i < (self.0.len() - 1) {
writeln!(f)?
}
}
Ok(())
}
}
impl<N: Display> Display for json_schema::NamespaceDefinition<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_indented(f, &BaseIndentation::none())
}
}
impl<N: Display> IndentedDisplay for json_schema::NamespaceDefinition<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
let total_len = self.common_types.len() + self.entity_types.len() + self.actions.len();
for (i, (n, ty)) in self.common_types.iter().enumerate() {
ty.annotations.fmt_indented(f, base_indentation.len())?;
writeln!(
f,
"{base_indentation}type {n} = {};",
Indented(&ty.ty, base_indentation)
)?;
if i < (total_len - 1) {
writeln!(f)?
}
}
for (i, (n, ty)) in self.entity_types.iter().enumerate() {
ty.annotations.fmt_indented(f, base_indentation.len())?;
writeln!(
f,
"{base_indentation}entity {n}{};",
Indented(ty, base_indentation)
)?;
if self.common_types.len() + i < (total_len - 1) {
writeln!(f)?
}
}
for (i, (n, a)) in self.actions.iter().enumerate() {
a.annotations.fmt_indented(f, base_indentation.len())?;
writeln!(
f,
"{base_indentation}action \"{}\"{};",
n.escape_debug(),
Indented(a, base_indentation)
)?;
if self.common_types.len() + self.entity_types.len() + i < (total_len - 1) {
writeln!(f)?
}
}
Ok(())
}
}
impl<N: Display> Display for json_schema::Type<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_indented(f, &BaseIndentation::none())
}
}
impl<N: Display> IndentedDisplay for json_schema::Type<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
match self {
json_schema::Type::Type { ty, .. } => match ty {
json_schema::TypeVariant::Boolean => write!(f, "__cedar::Bool"),
json_schema::TypeVariant::Entity { name } => write!(f, "{name}"),
json_schema::TypeVariant::EntityOrCommon { type_name } => {
write!(f, "{type_name}")
}
json_schema::TypeVariant::Extension { name } => write!(f, "__cedar::{name}"),
json_schema::TypeVariant::Long => write!(f, "__cedar::Long"),
json_schema::TypeVariant::Record(rty) => rty.fmt_indented(f, base_indentation),
json_schema::TypeVariant::Set { element } => {
write!(f, "Set<{}>", Indented(element.as_ref(), base_indentation))
} json_schema::TypeVariant::String => write!(f, "__cedar::String"),
},
json_schema::Type::CommonTypeRef { type_name, .. } => write!(f, "{type_name}"),
}
}
}
impl<N: Display> IndentedDisplay for json_schema::RecordType<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
let member_indentation = base_indentation.next();
write!(f, "{{")?;
for (i, (n, ty)) in self.attributes.iter().enumerate() {
if i == 0 {
writeln!(f)?;
}
ty.annotations.fmt_indented(f, member_indentation.len())?;
writeln!(
f,
"{member_indentation}{}{}: {}{}",
if is_normalized_ident(n) {
SmolStr::clone(n)
} else {
format_smolstr!("\"{}\"", n.escape_debug())
},
if ty.required { "" } else { "?" },
Indented(&ty.ty, &member_indentation),
if i < (self.attributes.len() - 1) {
","
} else {
""
}
)?;
}
write!(f, "{base_indentation}}}")?;
Ok(())
}
}
fn fmt_non_empty_slice<T: Display>(
f: &mut std::fmt::Formatter<'_>,
(head, tail): (&T, &[T]),
) -> std::fmt::Result {
write!(f, "[{head}")?;
for e in tail {
write!(f, ", {e}")?;
}
write!(f, "]")
}
impl<N: Display> Display for json_schema::EntityType<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_indented(f, &BaseIndentation::none())
}
}
impl<N: Display> IndentedDisplay for json_schema::EntityType<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
match &self.kind {
json_schema::EntityTypeKind::Standard(ty) => ty.fmt_indented(f, base_indentation),
json_schema::EntityTypeKind::Enum { choices } => write!(
f,
" enum [{}]",
choices
.iter()
.map(|e| format!("\"{}\"", e.escape_debug()))
.join(", ")
),
}
}
}
impl<N: Display> Display for json_schema::StandardEntityType<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_indented(f, &BaseIndentation::none())
}
}
impl<N: Display> IndentedDisplay for json_schema::StandardEntityType<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
if let Some(non_empty) = self.member_of_types.split_first() {
write!(f, " in ")?;
fmt_non_empty_slice(f, non_empty)?;
}
let ty = &self.shape;
if !ty.is_empty_record() {
write!(f, " = {}", Indented(&ty.0, base_indentation))?;
}
if let Some(tags) = &self.tags {
write!(f, " tags {tags}")?;
}
Ok(())
}
}
impl<N: Display> Display for json_schema::ActionType<N> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
self.fmt_indented(f, &BaseIndentation::none())
}
}
impl<N: Display> IndentedDisplay for json_schema::ActionType<N> {
fn fmt_indented(
&self,
f: &mut std::fmt::Formatter<'_>,
base_indentation: &BaseIndentation,
) -> std::fmt::Result {
if let Some(parents) = self.member_of.as_ref().and_then(|refs| refs.split_first()) {
write!(f, " in ")?;
fmt_non_empty_slice(f, parents)?;
}
if let Some(spec) = &self.applies_to {
match (
spec.principal_types.split_first(),
spec.resource_types.split_first(),
) {
(None, _) | (_, None) => {
write!(f, "")?;
}
(Some(ps), Some(rs)) => {
let member_indent = base_indentation.next();
write!(f, " appliesTo {{")?;
write!(f, "\n{member_indent}principal: ")?;
fmt_non_empty_slice(f, ps)?;
write!(f, ",\n{member_indent}resource: ")?;
fmt_non_empty_slice(f, rs)?;
if spec.context.0.is_empty_record() {
write!(f, ",\n{member_indent}context: {{}}")?;
} else {
write!(
f,
",\n{member_indent}context: {}",
Indented(&spec.context.0, &member_indent)
)?;
}
write!(f, "\n{base_indentation}}}")?;
}
}
}
Ok(())
}
}
#[derive(Debug, Diagnostic, Error)]
pub enum ToCedarSchemaSyntaxError {
#[diagnostic(transparent)]
#[error(transparent)]
NameCollisions(#[from] NameCollisionsError),
#[diagnostic(transparent)]
#[error(transparent)]
UnconvertibleEntityTypeShape(#[from] UnconvertibleEntityTypeShapeError),
}
#[derive(Debug, Error)]
#[error("There are name collisions: [{}]", .names.iter().join(", "))]
pub struct NameCollisionsError {
names: NonEmpty<InternalName>,
}
impl Diagnostic for NameCollisionsError {
impl_diagnostic_from_method_on_nonempty_field!(names, loc);
}
impl NameCollisionsError {
pub fn names(&self) -> impl Iterator<Item = &InternalName> {
self.names.iter()
}
}
#[derive(Debug, Error)]
#[error("The following entities have shapes that cannot be converted to Cedar schema syntax: [{}]", .names.iter().join(", "))]
pub struct UnconvertibleEntityTypeShapeError {
names: NonEmpty<InternalName>,
}
impl Diagnostic for UnconvertibleEntityTypeShapeError {
impl_diagnostic_from_method_on_nonempty_field!(names, loc);
fn help<'a>(&'a self) -> Option<Box<dyn Display + 'a>> {
Some(Box::new("Entity shapes may only be record types. In the Cedar schema syntax, they additionally may not reference common type definitions."))
}
}
impl UnconvertibleEntityTypeShapeError {
pub fn names(&self) -> impl Iterator<Item = &InternalName> {
self.names.iter()
}
}
pub fn json_schema_to_cedar_schema_str<N: Display>(
json_schema: &json_schema::Fragment<N>,
) -> Result<String, ToCedarSchemaSyntaxError> {
let mut name_collisions: Vec<InternalName> = Vec::new();
for (name, ns) in json_schema.0.iter().filter(|(name, _)| !name.is_none()) {
let entity_types: HashSet<InternalName> = ns
.entity_types
.keys()
.map(|ty_name| {
RawName::new_from_unreserved(ty_name.clone(), None).qualify_with_name(name.as_ref())
})
.collect();
let common_types: HashSet<InternalName> = ns
.common_types
.keys()
.map(|ty_name| {
RawName::new_from_unreserved(ty_name.clone().into(), None)
.qualify_with_name(name.as_ref())
})
.collect();
name_collisions.extend(entity_types.intersection(&common_types).cloned());
}
if let Some(non_empty_collisions) = NonEmpty::from_vec(name_collisions) {
return Err(NameCollisionsError {
names: non_empty_collisions,
}
.into());
}
let mut unsupported_shapes = vec![];
for (name, ns) in json_schema.0.iter() {
let unsupported = ns
.entity_types
.iter()
.filter(|(_, entity_type)| match &entity_type.kind {
json_schema::EntityTypeKind::Standard(json_schema::StandardEntityType {
shape:
json_schema::AttributesOrContext(json_schema::Type::Type {
ty: json_schema::TypeVariant::Record(..),
..
}),
..
}) => false,
json_schema::EntityTypeKind::Standard(..) => true,
json_schema::EntityTypeKind::Enum { .. } => false,
})
.map(|(ty_name, _)| {
RawName::new_from_unreserved(ty_name.clone(), None).qualify_with_name(name.as_ref())
});
unsupported_shapes.extend(unsupported);
}
if let Some(non_empty_unsupported_shapes) = NonEmpty::from_vec(unsupported_shapes) {
return Err(UnconvertibleEntityTypeShapeError {
names: non_empty_unsupported_shapes,
}
.into());
}
Ok(json_schema.to_string())
}
#[cfg(test)]
mod tests {
use crate::ast::InternalName;
use crate::extensions::Extensions;
use crate::validator::cedar_schema::fmt::UnconvertibleEntityTypeShapeError;
use crate::validator::{
cedar_schema::parser::parse_cedar_schema_fragment, json_schema, RawName,
};
use cool_asserts::assert_matches;
use nonempty::NonEmpty;
use similar_asserts::assert_eq;
#[track_caller]
fn test_round_trip(src: &str) {
let (cedar_schema, _) =
parse_cedar_schema_fragment(src, Extensions::none()).expect("should parse");
let printed_cedar_schema = cedar_schema.to_cedarschema().expect("should convert");
let (parsed_cedar_schema, _) =
parse_cedar_schema_fragment(&printed_cedar_schema, Extensions::none())
.expect("should parse");
assert_eq!(cedar_schema, parsed_cedar_schema);
}
#[test]
fn rfc_example() {
let src = "entity User = {
jobLevel: Long,
} tags Set<String>;
entity Document = {
owner: User,
} tags Set<String>;";
test_round_trip(src);
}
#[test]
fn annotations() {
let src = r#"@doc("this is the namespace")
namespace TinyTodo {
@doc("a common type representing a task")
type Task = {
@doc("task id")
"id": Long,
"name": String,
"state": String,
};
@doc("a common type representing a set of tasks")
type Tasks = Set<Task>;
@doc("an entity type representing a list")
@docComment("any entity type is a child of type `Application`")
entity List in [Application] = {
@doc("editors of a list")
"editors": Team,
"name": String,
"owner": User,
@doc("readers of a list")
"readers": Team,
"tasks": Tasks,
};
@doc("actions that a user can operate on a list")
action DeleteList, GetList, UpdateList appliesTo {
principal: [User],
resource: [List]
};
}"#;
test_round_trip(src);
}
#[test]
fn action_with_context() {
let src = r#"namespace example {
entity User {
"name": String,
};
entity Server {
"allowlist": Set<ipaddr>
};
action Connect appliesTo {
principal: [User],
resource: [Server],
context: {
"session": {
"origin": ipaddr,
}
}
};
}"#;
test_round_trip(src);
}
#[test]
fn attrs_types_roundtrip() {
test_round_trip(r#"entity Foo {a: Bool};"#);
test_round_trip(r#"entity Foo {a: Long};"#);
test_round_trip(r#"entity Foo {a: String};"#);
test_round_trip(r#"entity Foo {a: Set<Bool>};"#);
test_round_trip(r#"entity Foo {a: {b: Long}};"#);
test_round_trip(r#"entity Foo {a: {}};"#);
test_round_trip(
r#"
type A = Long;
entity Foo {a: A};
"#,
);
test_round_trip(
r#"
entity A;
entity Foo {a: A};
"#,
);
}
#[test]
fn enum_entities_roundtrip() {
test_round_trip(r#"entity Foo enum ["Bar", "Baz"];"#);
test_round_trip(r#"entity Foo enum ["Bar"];"#);
test_round_trip(r#"entity Foo enum ["\0\n\x7f"];"#);
test_round_trip(r#"entity enum enum ["enum"];"#);
}
#[test]
fn action_in_roundtrip() {
test_round_trip(r#"action Delete in Action::"Edit";"#);
test_round_trip(r#"action Delete in Action::"\n\x00";"#);
test_round_trip(r#"action Delete in [Action::"Edit", Action::"Destroy"];"#);
}
#[test]
fn primitives_roundtrip_to_entity_or_common() {
let schema_json = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"foo": { "type": "Long" },
"bar": { "type": "String" },
"baz": { "type": "Boolean" }
}
}
}
},
"actions": {}
}
}
);
let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
let cedar_schema = fragment.to_cedarschema().unwrap();
let (parsed_cedar_schema, _) =
parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
let expected_roundtrip = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"foo": {
"type": "EntityOrCommon",
"name": "__cedar::Long"
},
"bar": {
"type": "EntityOrCommon",
"name": "__cedar::String"
},
"baz": {
"type": "EntityOrCommon",
"name": "__cedar::Bool"
}
}
}
}
},
"actions": {}
}
}
);
assert_eq!(expected_roundtrip, roundtrip_json,);
}
#[test]
fn entity_type_reference_roundtrips_to_entity_or_common() {
let schema_json = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"owner": {
"type": "Entity",
"name": "User"
}
}
}
}
},
"actions": {}
}
}
);
let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
let cedar_schema = fragment.to_cedarschema().unwrap();
let (parsed_cedar_schema, _) =
parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
let expected_roundtrip = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"owner": {
"type": "EntityOrCommon",
"name": "User"
}
}
}
}
},
"actions": {}
}
}
);
assert_eq!(expected_roundtrip, roundtrip_json,);
}
#[test]
fn extension_type_roundtrips_to_entity_or_common() {
let schema_json = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"owner": {
"type": "Extension",
"name": "Decimal"
}
}
}
}
},
"actions": {}
}
}
);
let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
let cedar_schema = fragment.to_cedarschema().unwrap();
let (parsed_cedar_schema, _) =
parse_cedar_schema_fragment(&cedar_schema, Extensions::all_available()).unwrap();
let roundtrip_json = serde_json::to_value(parsed_cedar_schema).unwrap();
let expected_roundtrip = serde_json::json!(
{
"": {
"entityTypes": {
"User": { },
"Photo": {
"shape": {
"type": "Record",
"attributes": {
"owner": {
"type": "EntityOrCommon",
"name": "__cedar::Decimal"
}
}
}
}
},
"actions": {}
}
}
);
assert_eq!(expected_roundtrip, roundtrip_json,);
}
#[test]
fn test_formatting_roundtrip() {
use crate::validator::json_schema::Fragment;
let test_schema_str =
std::fs::read_to_string("src/validator/cedar_schema/testfiles/example.cedarschema")
.expect("missing test schema");
println!("{test_schema_str}");
let (f, _) = Fragment::from_cedarschema_str(&test_schema_str, Extensions::all_available())
.expect("test schema is valid");
assert_eq!(
f.to_cedarschema().expect("test schema can be displayed"),
test_schema_str,
)
}
#[test]
fn entity_type_with_common_type_shape_fails_conversion() {
let schema_json = serde_json::json!(
{
"": {
"commonTypes": {
"Task": {
"type": "Record",
"attributes": {}
}
},
"entityTypes": {
"User": {
"shape": {
"type": "Task"
}
}
},
"actions": {}
}
}
);
let expected_names = NonEmpty::new("User".parse::<InternalName>().unwrap());
let fragment: json_schema::Fragment<RawName> = serde_json::from_value(schema_json).unwrap();
let result = fragment.to_cedarschema();
assert_matches!(result, Err(crate::validator::cedar_schema::fmt::ToCedarSchemaSyntaxError::UnconvertibleEntityTypeShape(UnconvertibleEntityTypeShapeError{names})) => {
assert_eq!(names, expected_names)
});
}
}