diff --git a/Cargo.lock b/Cargo.lock index ff321477369..3f0ce16eadb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13191,9 +13191,9 @@ dependencies = [ [[package]] name = "scalar_api_reference" -version = "0.2.2" +version = "0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d67c85edd56c41f364603eff688a330f01dee99bdfc84c005d3bc3d0721d6b2" +checksum = "9c62b7de171e54b082e5a7865858b8d08cfa227dba5762efa255b81371d27dc1" dependencies = [ "rust-embed", "serde", diff --git a/Cargo.toml b/Cargo.toml index 7ad6f6cd81d..e11ccc4dca9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -291,7 +291,7 @@ roaring = { version = "0.11.2", default-features = fa rpds = { version = "1.1.2", default-features = false } rstest = { version = "0.26.1", default-features = false } rustc_version = { version = "0.4.1", default-features = false } -scalar_api_reference = { version = "0.2.2", default-features = false } +scalar_api_reference = { version = "0.2.12", default-features = false } scc = { version = "3.8.4", default-features = false } schemars = { version = "1.2.2" } semver = { version = "1.0.27", default-features = false } diff --git a/libs/@blockprotocol/type-system/rust/src/knowledge/property/mod.rs b/libs/@blockprotocol/type-system/rust/src/knowledge/property/mod.rs index 3c259bd7a4e..7932261e809 100644 --- a/libs/@blockprotocol/type-system/rust/src/knowledge/property/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/knowledge/property/mod.rs @@ -12,6 +12,8 @@ use core::{cmp::Ordering, fmt, iter, mem}; use std::{collections::HashMap, io}; use error_stack::{Report, ResultExt as _}; +#[cfg(feature = "schemars")] +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use self::metadata::{ ArrayMetadata, ObjectMetadata, PropertyArrayMetadata, PropertyMetadata, PropertyObjectMetadata, @@ -85,6 +87,44 @@ pub enum Property { Value(PropertyValue), } +#[cfg(feature = "schemars")] +impl JsonSchema for Property { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("Property") + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A list of properties, properties keyed by the base URL of their \ + property type, or a value of a data type.", + "anyOf": [ + generator.subschema_for::(), + generator.subschema_for::(), + generator.subschema_for::(), + ], + }) + } +} + +/// The schema of [`Property::Array`], which documents it as a component of its own. +#[cfg(feature = "schemars")] +struct PropertyArray; + +#[cfg(feature = "schemars")] +impl JsonSchema for PropertyArray { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PropertyArray") + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "type": "array", + "description": "A list of properties.", + "items": generator.subschema_for::(), + }) + } +} + /// Property data combined with its corresponding metadata. /// /// [`PropertyWithMetadata`] pairs each property with its metadata, maintaining the same diff --git a/libs/@blockprotocol/type-system/rust/src/knowledge/property/object.rs b/libs/@blockprotocol/type-system/rust/src/knowledge/property/object.rs index 246700102a0..6a52a436f41 100644 --- a/libs/@blockprotocol/type-system/rust/src/knowledge/property/object.rs +++ b/libs/@blockprotocol/type-system/rust/src/knowledge/property/object.rs @@ -1,3 +1,5 @@ +#[cfg(feature = "schemars")] +use alloc::borrow::Cow; #[cfg(feature = "postgres")] use core::error::Error; use std::collections::HashMap; @@ -7,6 +9,8 @@ use bytes::BytesMut; use error_stack::Report; #[cfg(feature = "postgres")] use postgres_types::{FromSql, IsNull, ToSql, Type}; +#[cfg(feature = "schemars")] +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use super::{ Property, PropertyPath, PropertyPathElement, PropertyPathError, PropertyWithMetadata, @@ -20,6 +24,26 @@ use crate::{knowledge::PropertyValue, ontology::BaseUrl}; #[cfg_attr(target_arch = "wasm32", derive(tsify::Tsify))] pub struct PropertyObject(HashMap); +#[cfg(feature = "schemars")] +impl JsonSchema for PropertyObject { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PropertyObject") + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "type": "object", + "description": "Properties keyed by the base URL of their property type.", + "propertyNames": generator.subschema_for::(), + "additionalProperties": generator.subschema_for::(), + "examples": [{ + "https://hash.ai/@h/types/property-type/shortname/": "alice", + "https://hash.ai/@h/types/property-type/preferred-name/": "Alice", + }], + }) + } +} + impl PropertyObject { #[must_use] pub const fn new(properties: HashMap) -> Self { diff --git a/libs/@blockprotocol/type-system/rust/src/knowledge/value/mod.rs b/libs/@blockprotocol/type-system/rust/src/knowledge/value/mod.rs index b7076c614d5..08a5cfeca13 100644 --- a/libs/@blockprotocol/type-system/rust/src/knowledge/value/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/knowledge/value/mod.rs @@ -1,5 +1,7 @@ pub mod metadata; +#[cfg(feature = "schemars")] +use alloc::borrow::Cow; #[cfg(feature = "postgres")] use core::error::Error; use core::fmt::{self, Debug}; @@ -10,6 +12,8 @@ use bytes::BytesMut; use hash_codec::numeric::Real; #[cfg(feature = "postgres")] use postgres_types::{FromSql, IsNull, Json, ToSql, Type}; +#[cfg(feature = "schemars")] +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; use serde::Serialize as _; pub use self::metadata::ValueMetadata; @@ -62,6 +66,20 @@ pub enum PropertyValue { Object(HashMap), } +#[cfg(feature = "schemars")] +impl JsonSchema for PropertyValue { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PropertyValue") + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "description": "A value of a data type: `null`, a boolean, a string, a number, an \ + array or an object.", + }) + } +} + impl fmt::Display for PropertyValue { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { self.serialize(fmt) diff --git a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs index 856b9e7d3af..8f08ba32422 100644 --- a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs @@ -226,6 +226,25 @@ impl ToSchema<'_> for BaseUrl { } } +#[cfg(feature = "schemars")] +impl JsonSchema for BaseUrl { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("BaseUrl") + } + + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!({ + "type": "string", + "title": "Base URL", + "description": "The base URL of a SemType ontology type (the $id of the schema without \ + the versioned suffix). It must end with a trailing slash.", + "format": "uri", + "maxLength": 2048, + "pattern": "/$", + }) + } +} + #[cfg(feature = "postgres")] impl<'a> FromSql<'a> for BaseUrl { fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { diff --git a/libs/@blockprotocol/type-system/rust/src/principal/actor_group/web.rs b/libs/@blockprotocol/type-system/rust/src/principal/actor_group/web.rs index ce7d9d3c92f..b2ae9c9c295 100644 --- a/libs/@blockprotocol/type-system/rust/src/principal/actor_group/web.rs +++ b/libs/@blockprotocol/type-system/rust/src/principal/actor_group/web.rs @@ -18,6 +18,16 @@ use crate::{knowledge::entity::id::EntityUuid, principal::role::WebRoleId}; )] #[cfg_attr(feature = "codegen", derive(specta::Type))] #[cfg_attr(feature = "utoipa", derive(utoipa::ToSchema))] +#[cfg_attr( + feature = "schemars", + derive(schemars::JsonSchema), + schemars( + inline, + with = "Uuid", + description = "The UUID of a web.", + example = "8d86c8c3-d66d-43d1-859b-676c3bcaeadc" + ) +)] #[cfg_attr( feature = "postgres", derive(postgres_types::ToSql, postgres_types::FromSql), diff --git a/libs/@local/graph/api/src/rest/documentation/mod.rs b/libs/@local/graph/api/src/rest/documentation/mod.rs index 3c952e164b3..7653c1c9762 100644 --- a/libs/@local/graph/api/src/rest/documentation/mod.rs +++ b/libs/@local/graph/api/src/rest/documentation/mod.rs @@ -20,8 +20,7 @@ const PUBLIC_REFERENCE_PATH: &str = "/"; /// # Panics /// /// Panics if a document or a source list cannot be serialized, route paths overlap, the Scalar -/// bundle is missing or not UTF-8, the embedded Scalar configuration is invalid, or the Scalar -/// optional-auth correction does not match exactly once. +/// bundle is missing, or the embedded Scalar configuration is invalid. pub(crate) fn routes(apis: &[Api]) -> Router { let legacy_path = format!("{}/legacy", internal::PREFIX); let legacy_document_path = format!("{legacy_path}/openapi.json"); diff --git a/libs/@local/graph/api/src/rest/documentation/scalar.rs b/libs/@local/graph/api/src/rest/documentation/scalar.rs index ddbcf5df900..5a1b891144d 100644 --- a/libs/@local/graph/api/src/rest/documentation/scalar.rs +++ b/libs/@local/graph/api/src/rest/documentation/scalar.rs @@ -38,34 +38,9 @@ fn render(configuration: &str, sources: Value, bundle: &str) -> Result = LazyLock::new(|| { - let mut javascript = - String::from_utf8(get_asset("scalar.js").expect("the Scalar bundle should exist")) - .expect("the Scalar bundle should be UTF-8"); - - // @scalar/api-reference 1.49.2 incorrectly makes an empty security alternative mandatory when - // another alternative combines schemes. - for (original, corrected) in [ - ( - "!t.some((e=>Object.keys(e).length>1))&&e.length0===Object.keys(e).length))&&!t", - "return e.some((e=>0===Object.keys(e).length))", - ), - ] { - assert_eq!( - javascript.matches(original).count(), - 1, - "the Scalar optional-auth correction should match exactly once" - ); - javascript = javascript.replacen(original, corrected, 1); - } - - Bytes::from(javascript) -}); +/// The Scalar bundle. +static JAVASCRIPT: LazyLock = + LazyLock::new(|| Bytes::from(get_asset("scalar.js").expect("the Scalar bundle should exist"))); /// Serves a reference over `sources` at `path`, with the bundle it loads underneath. pub(super) fn routes(path: &str, sources: &[Source]) -> Router { diff --git a/libs/@local/graph/api/src/rest/public/entities/v1/create.rs b/libs/@local/graph/api/src/rest/public/entities/v1/create.rs new file mode 100644 index 00000000000..e5de8ebfd95 --- /dev/null +++ b/libs/@local/graph/api/src/rest/public/entities/v1/create.rs @@ -0,0 +1,389 @@ +//! `POST /`: create entities. + +use alloc::{borrow::Cow, collections::BTreeSet}; +use core::error::Error; + +use aide::transform::TransformOperation; +use axum::extract::State; +use error_stack::{Report, ResultExt as _}; +use hash_graph_store::{ + entity::{CreateEntityParams, EntityStore as _}, + error::EntityCreationError, + pool::StorePool, +}; +use hash_middleware::problem::{InternalServerError, ServiceUnavailable}; +use http::StatusCode; +use problematic::{Answer, Expose, Problem, ProblemType, ProblemVariant, Rejection, Variant}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use type_system::{ + knowledge::{ + entity::provenance::ProvidedEntityEditionProvenance, + property::{PropertyObject, PropertyObjectWithMetadata}, + }, + ontology::VersionedUrl, + principal::{actor::ActorId, actor_group::WebId}, + provenance::{OriginProvenance, OriginType}, +}; + +use super::{ + entity::{Entity, LiveEntityId}, + property_metadata::{PropertyMetadataMismatch, PropertyObjectMetadata}, +}; +use crate::rest::{ + ApiState, + credentials::Actor, + extract::Json, + public::Credentials, + routing::{self, Route}, +}; + +/// The entities to create. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct CreateEntitiesRequest { + /// The entities to create, in one transaction, at least one. + entities: NewEntities, +} + +/// The entities to create, at least one. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(try_from = "Vec")] +#[schemars(inline, extend("minItems" = 1))] +struct NewEntities(Vec); + +/// The request names no entity. +#[derive(Debug, derive_more::Display)] +#[display("a request has to name at least one entity")] +struct NoEntity; + +impl TryFrom> for NewEntities { + type Error = NoEntity; + + fn try_from(entities: Vec) -> Result { + if entities.is_empty() { + return Err(NoEntity); + } + Ok(Self(entities)) + } +} + +/// An entity to create. +#[derive(Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct NewEntityFields { + /// The web to create the entity in. + web_id: WebId, + /// The entity types the entity is an instance of, at least one. + entity_type_ids: EntityTypeIds, + /// The entity's properties, keyed by the base URL of their property type. + properties: PropertyObject, + /// The data types of the values in `properties`, one for every value. + property_metadata: PropertyObjectMetadata, +} + +/// An entity to create. +/// +/// Deserializing it checks that the property metadata fits the properties. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(try_from = "NewEntityFields")] +#[schemars(with = "NewEntityFields", description = "An entity to create.")] +struct NewEntity { + web_id: WebId, + entity_type_ids: EntityTypeIds, + properties_with_metadata: PropertyObjectWithMetadata, +} + +impl TryFrom for NewEntity { + type Error = PropertyMetadataMismatch; + + fn try_from(fields: NewEntityFields) -> Result { + Ok(Self { + web_id: fields.web_id, + entity_type_ids: fields.entity_type_ids, + properties_with_metadata: fields.property_metadata.join(fields.properties)?, + }) + } +} + +/// The entity types of an entity to create, at least one. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(try_from = "BTreeSet")] +#[schemars(inline, extend("minItems" = 1))] +struct EntityTypeIds(BTreeSet); + +/// The entity names no entity type. +#[derive(Debug, derive_more::Display)] +#[display("an entity has to name at least one entity type")] +struct NoEntityType; + +impl TryFrom> for EntityTypeIds { + type Error = NoEntityType; + + fn try_from(entity_type_ids: BTreeSet) -> Result { + if entity_type_ids.is_empty() { + return Err(NoEntityType); + } + Ok(Self(entity_type_ids)) + } +} + +/// The created entities. +#[derive(Serialize, JsonSchema)] +struct CreateEntitiesResponse { + /// The created entities, in the order of the request. + entities: Vec, +} + +/// The caller may not create an entity or instantiate an entity type or a type it inherits from. +#[derive(Serialize, JsonSchema, derive_more::Display)] +#[display("The caller may not create the entities.")] +struct EntityCreationForbidden; + +impl ProblemVariant for EntityCreationForbidden { + const TYPE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Forbidden"), + status: StatusCode::FORBIDDEN, + }; + + fn example() -> Option { + Some(Self) + } +} + +/// One or more entity types do not exist. +#[derive(Serialize, JsonSchema, derive_more::Display)] +#[display("One or more entity types do not exist.")] +struct UnknownEntityTypes; + +impl ProblemVariant for UnknownEntityTypes { + const TYPE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Unprocessable Content"), + status: StatusCode::UNPROCESSABLE_ENTITY, + }; + + fn example() -> Option { + Some(Self) + } +} + +/// One or more entity types are archived. +#[derive(Serialize, JsonSchema, derive_more::Display)] +#[display("One or more entity types are archived.")] +struct ArchivedEntityTypes; + +impl ProblemVariant for ArchivedEntityTypes { + const TYPE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Unprocessable Content"), + status: StatusCode::UNPROCESSABLE_ENTITY, + }; + + fn example() -> Option { + Some(Self) + } +} + +/// The entity types of an entity define a property differently. +#[derive(Serialize, JsonSchema, derive_more::Display)] +#[display("The entity types of an entity define a property differently.")] +struct IncompatibleEntityTypes; + +impl ProblemVariant for IncompatibleEntityTypes { + const TYPE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Unprocessable Content"), + status: StatusCode::UNPROCESSABLE_ENTITY, + }; + + fn example() -> Option { + Some(Self) + } +} + +/// One or more entities do not match their entity types. +#[derive(Serialize, JsonSchema, derive_more::Display)] +#[display("One or more entities do not match their entity types.")] +struct InvalidEntities; + +impl ProblemVariant for InvalidEntities { + const TYPE: ProblemType = ProblemType { + type_uri: Cow::Borrowed("about:blank"), + title: Cow::Borrowed("Unprocessable Content"), + status: StatusCode::UNPROCESSABLE_ENTITY, + }; + + fn example() -> Option { + Some(Self) + } +} + +/// The ways [`create_entities`] refuses a request its extractors accept. +struct CreateEntitiesProblem; + +impl Problem for CreateEntitiesProblem { + const VARIANTS: &'static [Variant] = &[ + Variant::of::(), + Variant::of::(), + Variant::of::(), + Variant::of::(), + Variant::of::(), + Variant::of::(), + Variant::of::(), + ]; +} + +impl Expose for Report { + fn expose(&self) -> Answer<'_, CreateEntitiesProblem> { + match self.current_context() { + EntityCreationError::InstantiationForbidden { .. } + | EntityCreationError::CreationForbidden { .. } => Answer::new(EntityCreationForbidden), + EntityCreationError::UnknownEntityTypes { .. } => Answer::new(UnknownEntityTypes), + EntityCreationError::ArchivedEntityTypes { .. } => Answer::new(ArchivedEntityTypes), + EntityCreationError::IncompatibleEntityTypes { .. } => { + Answer::new(IncompatibleEntityTypes) + } + EntityCreationError::InvalidEntities => Answer::new(InvalidEntities), + EntityCreationError::Store => Answer::new(InternalServerError), + } + } +} + +#[derive(Debug, derive_more::Display)] +enum CreateEntitiesError { + #[display("no store could be acquired")] + Unavailable, + #[display("the store returned a draft")] + Draft, +} + +impl Error for CreateEntitiesError {} + +impl Expose for Report { + fn expose(&self) -> Answer<'_, CreateEntitiesProblem> { + match self.current_context() { + CreateEntitiesError::Unavailable => Answer::new(ServiceUnavailable), + CreateEntitiesError::Draft => Answer::new(InternalServerError), + } + } +} + +async fn create_entities( + State(state): State>, + actor: Actor, + Json(CreateEntitiesRequest { + entities: NewEntities(entities), + }): Json, +) -> Result, Rejection> +where + S: StorePool + Send + Sync + 'static, +{ + let actor_id = ActorId::from(actor); + let params = entities + .into_iter() + .map(|entity| CreateEntityParams { + web_id: entity.web_id, + entity_uuid: None, + decision_time: None, + entity_type_ids: entity.entity_type_ids.0.into_iter().collect(), + properties: entity.properties_with_metadata, + confidence: None, + link_data: None, + draft: false, + policies: Vec::new(), + provenance: ProvidedEntityEditionProvenance { + sources: Vec::new(), + actor_type: actor_id.actor_type(), + origin: OriginProvenance::from_empty_type(OriginType::Api), + }, + read_only: false, + }) + .collect::>(); + + let mut store = state + .acquire() + .await + .change_context(CreateEntitiesError::Unavailable)?; + let created = store.create_entities(actor_id, params).await?; + + let entities = created + .into_iter() + .map(|entity| { + let entity_id = LiveEntityId::try_from(entity.metadata.record_id.entity_id) + .map_err(|error| Report::new(error).change_context(CreateEntitiesError::Draft))?; + Ok(Entity::new(entity_id, entity)) + }) + .collect::, Report>>()?; + + Ok(Json(CreateEntitiesResponse { entities })) +} + +fn document_create_entities(operation: TransformOperation<'_>) -> TransformOperation<'_> { + operation + .id("createEntities") + .summary("Create entities") + .description( + "Creates the entities in one transaction: either all of them are created or none. \ + Returns them in the order of the request.", + ) +} + +pub(super) fn route() -> Route> +where + S: StorePool + Send + Sync + 'static, +{ + routing::post(create_entities::, document_create_entities) +} + +#[cfg(test)] +mod tests { + use core::assert_matches; + + use serde_json::json; + + use super::CreateEntitiesRequest; + + const WEB: &str = "8d86c8c3-d66d-43d1-859b-676c3bcaeadc"; + const PERSON: &str = "https://example.com/@alice/types/entity-type/person/v/1"; + const NAME: &str = "https://example.com/@alice/types/property-type/name/"; + + fn parse(request: serde_json::Value) -> Result { + serde_json::from_value(request) + } + + #[test] + fn request_no_entities() { + assert_matches!( + parse(json!({ "entities": [] })), + Err(error) if error.to_string().contains("at least one entity") + ); + } + + #[test] + fn request_no_entity_type() { + assert_matches!( + parse(json!({ "entities": [{ + "webId": WEB, + "entityTypeIds": [], + "properties": {}, + "propertyMetadata": {}, + }] })), + Err(error) if error.to_string().contains("at least one entity type") + ); + } + + #[test] + fn request_metadata_mismatch() { + assert_matches!( + parse(json!({ "entities": [{ + "webId": WEB, + "entityTypeIds": [PERSON], + "properties": { NAME: "Alice" }, + "propertyMetadata": {}, + }] })), + Err(error) if error.to_string().contains(&format!("has no entry for `[\"{NAME}\"]`")) + ); + } +} diff --git a/libs/@local/graph/api/src/rest/public/entities/v1/entity.rs b/libs/@local/graph/api/src/rest/public/entities/v1/entity.rs index 280f1f0872b..24d424c2c56 100644 --- a/libs/@local/graph/api/src/rest/public/entities/v1/entity.rs +++ b/libs/@local/graph/api/src/rest/public/entities/v1/entity.rs @@ -10,29 +10,42 @@ use hash_graph_store::{entity::EntityStore as _, pool::StorePool}; use hash_middleware::problem::{InternalServerError, ServiceUnavailable}; use http::StatusCode; use problematic::{Answer, Expose, Problem, ProblemType, ProblemVariant, Rejection, Variant}; -use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use type_system::{ - knowledge::entity::{EntityId, id::EntityUuid}, + knowledge::{ + self, + entity::{EntityId, id::EntityUuid}, + property::PropertyObject, + }, ontology::VersionedUrl, principal::actor_group::WebId, }; use uuid::Uuid; +use super::property_metadata::PropertyObjectMetadata; use crate::rest::{ ApiState, credentials::Actor, extract::{Json, Path}, public::Credentials, - routing::{self, Routes}, + routing::{self, Route}, }; /// An entity's web ID and UUID, joined by `~`. /// /// Unlike [`EntityId`], it never names a draft. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, derive_more::Display)] +#[derive( + Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, derive_more::Display, +)] #[serde(try_from = "EntityId", into = "EntityId")] -struct LiveEntityId(EntityId); +#[schemars( + with = "String", + rename = "EntityId", + description = "An entity's web ID and UUID, joined by `~`.", + example = "8d86c8c3-d66d-43d1-859b-676c3bcaeadc~be2c5b2f-e3e1-4f26-a0c4-0f8b5e9c4a1d" +)] +pub(super) struct LiveEntityId(EntityId); impl LiveEntityId { const fn new(web_id: WebId, entity_uuid: EntityUuid) -> Self { @@ -47,7 +60,9 @@ impl LiveEntityId { /// The entity ID names a draft. #[derive(Debug, derive_more::Display)] #[display("the entity ID names a draft")] -struct DraftEntityIdError; +pub(super) struct DraftEntityIdError; + +impl Error for DraftEntityIdError {} impl TryFrom for LiveEntityId { type Error = DraftEntityIdError; @@ -66,22 +81,6 @@ impl From for EntityId { } } -impl JsonSchema for LiveEntityId { - fn schema_name() -> Cow<'static, str> { - Cow::Borrowed("EntityId") - } - - fn json_schema(_: &mut SchemaGenerator) -> Schema { - json_schema!({ - "type": "string", - "description": "An entity's web ID and UUID, joined by `~`.", - "examples": [ - "8d86c8c3-d66d-43d1-859b-676c3bcaeadc~be2c5b2f-e3e1-4f26-a0c4-0f8b5e9c4a1d", - ], - }) - } -} - /// The path of one entity. #[derive(Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] @@ -92,9 +91,28 @@ struct EntityPath { /// An entity the caller may view. #[derive(Serialize, JsonSchema)] -struct Entity { +#[serde(rename_all = "camelCase")] +pub(super) struct Entity { /// What identifies and classifies the entity. metadata: EntityMetadata, + /// The entity's properties, keyed by the base URL of their property type. + properties: PropertyObject, + /// The data types of the values in `properties`. + property_metadata: PropertyObjectMetadata, +} + +impl Entity { + /// The public form of `entity`, which `entity_id` identifies. + pub(super) fn new(entity_id: LiveEntityId, entity: knowledge::Entity) -> Self { + Self { + metadata: EntityMetadata { + entity_id, + entity_type_ids: entity.metadata.entity_type_ids.into_iter().collect(), + }, + properties: entity.properties, + property_metadata: PropertyObjectMetadata::from_stored(entity.metadata.properties), + } + } } /// What identifies and classifies an entity. @@ -192,29 +210,23 @@ where return Err(Report::new(GetEntityError::Archived(entity_id)).into()); } - Ok(Json(Entity { - metadata: EntityMetadata { - entity_id, - entity_type_ids: entity.metadata.entity_type_ids.into_iter().collect(), - }, - })) + Ok(Json(Entity::new(entity_id, entity))) } fn document_get_entity(operation: TransformOperation<'_>) -> TransformOperation<'_> { operation .id("getEntity") .summary("Get an entity") - .description("Returns the entity's ID and the entity types of its current edition.") + .description( + "Returns the entity's ID, the entity types, and the properties with the data types of \ + their values, of its current edition. `propertyMetadata` leaves out a value without \ + a stored data type, and a list that contains one.", + ) } -pub(super) fn routes() -> Routes> +pub(super) fn route() -> Route> where S: StorePool + Send + Sync + 'static, { - Routes::new() - .route( - "/{entityId}", - routing::get(get_entity::, document_get_entity), - ) - .tag("Entity") + routing::get(get_entity::, document_get_entity) } diff --git a/libs/@local/graph/api/src/rest/public/entities/v1/mod.rs b/libs/@local/graph/api/src/rest/public/entities/v1/mod.rs index 6ffc2a4104f..a518acafdde 100644 --- a/libs/@local/graph/api/src/rest/public/entities/v1/mod.rs +++ b/libs/@local/graph/api/src/rest/public/entities/v1/mod.rs @@ -1,9 +1,21 @@ +mod create; mod entity; +mod property_metadata; use aide::openapi::Info; use hash_graph_store::pool::StorePool; -use crate::rest::{Api, ApiState, documentation, openapi, public}; +use crate::rest::{Api, ApiState, documentation, openapi, public, routing::Routes}; + +fn routes() -> Routes> +where + S: StorePool + Send + Sync + 'static, +{ + Routes::new() + .route("/", create::route::()) + .route("/{entityId}", entity::route::()) + .tag("Entity") +} pub(super) fn api() -> Api> where @@ -17,7 +29,7 @@ where description: Some(documentation::OVERVIEW.to_owned()), ..Info::default() }, - entity::routes::, + routes::, public::document, ) } diff --git a/libs/@local/graph/api/src/rest/public/entities/v1/property_metadata.rs b/libs/@local/graph/api/src/rest/public/entities/v1/property_metadata.rs new file mode 100644 index 00000000000..47aae2e1aec --- /dev/null +++ b/libs/@local/graph/api/src/rest/public/entities/v1/property_metadata.rs @@ -0,0 +1,429 @@ +//! The data types of an entity's property values, in the shape of its properties. + +use alloc::borrow::Cow; +use core::{error::Error, fmt}; +use std::collections::HashMap; + +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; +use type_system::{ + knowledge::{ + PropertyValue, + property::{ + Property, PropertyArrayWithMetadata, PropertyObject, PropertyObjectWithMetadata, + PropertyValueWithMetadata, PropertyWithMetadata, metadata as stored, + }, + value::{ValueMetadata, metadata::ValueProvenance}, + }, + ontology::{BaseUrl, VersionedUrl}, +}; + +/// The metadata of properties, keyed by the base URL of their property type. +#[derive(Debug, PartialEq, Serialize, Deserialize)] +pub(super) struct PropertyObjectMetadata(HashMap); + +/// The metadata of a list of properties, one entry per property in the same order. +#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +pub(super) struct PropertyArrayMetadata(Vec); + +/// The metadata of a property value. +#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct PropertyValueMetadata { + /// The data type of the value. + data_type_id: VersionedUrl, +} + +/// The metadata of a property: of a list, of properties keyed by base URL, or of a value. +#[derive(Debug, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub(super) enum PropertyMetadata { + Array(PropertyArrayMetadata), + Object(PropertyObjectMetadata), + Value(PropertyValueMetadata), +} + +// A derived schema describes the keys by the pattern of `BaseUrl` instead of referencing it as +// `PropertyObject` does. +impl JsonSchema for PropertyObjectMetadata { + fn schema_name() -> Cow<'static, str> { + Cow::Borrowed("PropertyObjectMetadata") + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + json_schema!({ + "type": "object", + "description": "The metadata of properties, keyed by the base URL of their property \ + type.", + "propertyNames": generator.subschema_for::(), + "additionalProperties": generator.subschema_for::(), + "examples": [{ + "https://hash.ai/@h/types/property-type/shortname/": { + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + }, + "https://hash.ai/@h/types/property-type/preferred-name/": { + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + }, + }], + }) + } +} + +/// A position in an entity's properties. +#[derive(Debug, Clone)] +enum PathElement { + Key(BaseUrl), + Index(usize), +} + +/// A path through an entity's properties, written like `["https://…/name/"][1]`. +#[derive(Debug, Clone, Default)] +pub(super) struct PropertyMetadataPath(Vec); + +impl fmt::Display for PropertyMetadataPath { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + for element in &self.0 { + match element { + PathElement::Key(key) => write!(fmt, "[\"{key}\"]")?, + PathElement::Index(index) => write!(fmt, "[{index}]")?, + } + } + Ok(()) + } +} + +/// The property metadata does not fit the properties. +#[derive(Debug, derive_more::Display)] +pub(super) enum PropertyMetadataMismatch { + #[display("`propertyMetadata` has no entry for `{_0}`, which `properties` contains")] + Missing(PropertyMetadataPath), + #[display("`propertyMetadata` names `{_0}`, which `properties` does not contain")] + Unknown(PropertyMetadataPath), + #[display("`propertyMetadata` at `{_0}` does not have the shape of the property there")] + Shape(PropertyMetadataPath), + #[display( + "`propertyMetadata` at `{path}` has {actual} entries, but the list in `properties` has \ + {expected}" + )] + Length { + path: PropertyMetadataPath, + expected: usize, + actual: usize, + }, +} + +impl Error for PropertyMetadataMismatch {} + +impl PropertyObjectMetadata { + /// The public form of `metadata`, leaving out every value without a data type and every list + /// with such a value. + pub(super) fn from_stored(metadata: stored::PropertyObjectMetadata) -> Self { + Self( + metadata + .value + .into_iter() + .filter_map(|(key, metadata)| { + PropertyMetadata::from_stored(metadata).map(|metadata| (key, metadata)) + }) + .collect(), + ) + } + + /// Joins `properties` with this metadata, which has to cover every value in them. + /// + /// Where the metadata gives a data type, the property is a value, also where `properties` + /// reads it as a list or as properties keyed by base URL, such as `{}` of an object data type. + /// + /// # Errors + /// + /// - [`Missing`] if the metadata has no entry for a property in `properties` + /// - [`Unknown`] if the metadata names a property that `properties` does not contain + /// - [`Shape`] if the metadata of a property does not have the shape of the property + /// - [`Length`] if the metadata of a list does not have one entry per property in it + /// + /// [`Missing`]: PropertyMetadataMismatch::Missing + /// [`Unknown`]: PropertyMetadataMismatch::Unknown + /// [`Shape`]: PropertyMetadataMismatch::Shape + /// [`Length`]: PropertyMetadataMismatch::Length + pub(super) fn join( + self, + properties: PropertyObject, + ) -> Result { + self.join_at(properties, &mut PropertyMetadataPath::default()) + } + + fn join_at( + mut self, + properties: PropertyObject, + path: &mut PropertyMetadataPath, + ) -> Result { + let mut value = HashMap::with_capacity(properties.properties().len()); + for (key, property) in properties { + path.0.push(PathElement::Key(key.clone())); + let Some(metadata) = self.0.remove(&key) else { + return Err(PropertyMetadataMismatch::Missing(path.clone())); + }; + let property = metadata.join_at(property, path)?; + path.0.pop(); + value.insert(key, property); + } + + if let Some(key) = self.0.into_keys().next() { + path.0.push(PathElement::Key(key)); + return Err(PropertyMetadataMismatch::Unknown(path.clone())); + } + + Ok(PropertyObjectWithMetadata { + value, + metadata: stored::ObjectMetadata::default(), + }) + } +} + +impl PropertyMetadata { + fn from_stored(metadata: stored::PropertyMetadata) -> Option { + match metadata { + stored::PropertyMetadata::Array(array) => array + .value + .into_iter() + .map(Self::from_stored) + .collect::>>() + .map(|elements| Self::Array(PropertyArrayMetadata(elements))), + stored::PropertyMetadata::Object(object) => { + Some(Self::Object(PropertyObjectMetadata::from_stored(object))) + } + stored::PropertyMetadata::Value(value) => value + .metadata + .data_type_id + .map(|data_type_id| Self::Value(PropertyValueMetadata { data_type_id })), + } + } + + fn join_at( + self, + property: Property, + path: &mut PropertyMetadataPath, + ) -> Result { + match (self, property) { + (Self::Array(PropertyArrayMetadata(elements)), Property::Array(properties)) => { + if elements.len() != properties.len() { + return Err(PropertyMetadataMismatch::Length { + path: path.clone(), + expected: properties.len(), + actual: elements.len(), + }); + } + + let mut value = Vec::with_capacity(properties.len()); + for (index, (property, metadata)) in + properties.into_iter().zip(elements).enumerate() + { + path.0.push(PathElement::Index(index)); + value.push(metadata.join_at(property, path)?); + path.0.pop(); + } + + Ok(PropertyWithMetadata::Array(PropertyArrayWithMetadata { + value, + metadata: stored::ArrayMetadata::default(), + })) + } + (Self::Object(object), Property::Object(properties)) => Ok( + PropertyWithMetadata::Object(object.join_at(properties, path)?), + ), + (Self::Value(PropertyValueMetadata { data_type_id }), property) => { + Ok(PropertyWithMetadata::Value(PropertyValueWithMetadata { + value: into_value(property), + metadata: ValueMetadata { + provenance: ValueProvenance::default(), + confidence: None, + data_type_id: Some(data_type_id), + original_data_type_id: None, + canonical: HashMap::new(), + }, + })) + } + (Self::Array(_) | Self::Object(_), _) => { + Err(PropertyMetadataMismatch::Shape(path.clone())) + } + } + } +} + +/// The value that `property` stands for when its metadata gives it a data type. +fn into_value(property: Property) -> PropertyValue { + match property { + Property::Array(properties) => { + PropertyValue::Array(properties.into_iter().map(into_value).collect()) + } + Property::Object(properties) => PropertyValue::Object( + properties + .into_iter() + .map(|(key, property)| (String::from(key), into_value(property))) + .collect(), + ), + Property::Value(value) => value, + } +} + +#[cfg(test)] +mod tests { + use core::assert_matches; + + use serde_json::json; + use type_system::{ + knowledge::{ + PropertyValue, + property::{ + PropertyObject, PropertyObjectWithMetadata, PropertyValueWithMetadata, + PropertyWithMetadata, metadata as stored, + }, + }, + ontology::BaseUrl, + }; + + use super::{PropertyMetadataMismatch, PropertyObjectMetadata}; + + const NAMES: &str = "https://example.com/@alice/types/property-type/names/"; + const ADDRESS: &str = "https://example.com/@alice/types/property-type/address/"; + const CITY: &str = "https://example.com/@alice/types/property-type/city/"; + const TEXT: &str = "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1"; + const OBJECT: &str = "https://blockprotocol.org/@blockprotocol/types/data-type/object/v/1"; + + fn properties() -> PropertyObject { + serde_json::from_value(json!({ + NAMES: ["Alice", "Ally"], + ADDRESS: { CITY: "Berlin" }, + })) + .expect("should be a property object") + } + + fn metadata(value: serde_json::Value) -> PropertyObjectMetadata { + serde_json::from_value(value).expect("should be property metadata") + } + + fn join( + value: serde_json::Value, + ) -> Result { + metadata(value).join(properties()) + } + + #[test] + fn join_missing() { + assert_matches!( + join(json!({ NAMES: [{ "dataTypeId": TEXT }, { "dataTypeId": TEXT }], ADDRESS: {} })), + Err(PropertyMetadataMismatch::Missing(path)) + if path.to_string() == format!("[\"{ADDRESS}\"][\"{CITY}\"]") + ); + } + + #[test] + fn join_unknown() { + assert_matches!( + join(json!({ + NAMES: [{ "dataTypeId": TEXT }, { "dataTypeId": TEXT }], + ADDRESS: { CITY: { "dataTypeId": TEXT }, NAMES: { "dataTypeId": TEXT } }, + })), + Err(PropertyMetadataMismatch::Unknown(path)) + if path.to_string() == format!("[\"{ADDRESS}\"][\"{NAMES}\"]") + ); + } + + #[test] + fn join_list_short() { + assert_matches!( + join(json!({ NAMES: [{ "dataTypeId": TEXT }], ADDRESS: { CITY: { "dataTypeId": TEXT } } })), + Err(PropertyMetadataMismatch::Length { path, expected: 2, actual: 1 }) + if path.to_string() == format!("[\"{NAMES}\"]") + ); + } + + #[test] + fn join_list_long() { + assert_matches!( + join(json!({ + NAMES: [{ "dataTypeId": TEXT }, { "dataTypeId": TEXT }, { "dataTypeId": TEXT }], + ADDRESS: { CITY: { "dataTypeId": TEXT } }, + })), + Err(PropertyMetadataMismatch::Length { path, expected: 2, actual: 3 }) + if path.to_string() == format!("[\"{NAMES}\"]") + ); + } + + #[test] + fn join_shape() { + assert_matches!( + join(json!({ + NAMES: [{ "dataTypeId": TEXT }, { "dataTypeId": TEXT }], + ADDRESS: [{ "dataTypeId": TEXT }], + })), + Err(PropertyMetadataMismatch::Shape(path)) if path.to_string() == format!("[\"{ADDRESS}\"]") + ); + } + + #[test] + fn join_shape_in_list() { + assert_matches!( + join(json!({ + NAMES: [{ "dataTypeId": TEXT }, {}], + ADDRESS: { CITY: { "dataTypeId": TEXT } }, + })), + Err(PropertyMetadataMismatch::Shape(path)) if path.to_string() == format!("[\"{NAMES}\"][1]") + ); + } + + #[test] + fn join_value_read_as_properties() { + let properties: PropertyObject = + serde_json::from_value(json!({ ADDRESS: {} })).expect("should be a property object"); + let joined = metadata(json!({ ADDRESS: { "dataTypeId": OBJECT } })) + .join(properties) + .expect("should fit the properties"); + + assert_matches!( + joined.value.get(&BaseUrl::new(ADDRESS.to_owned()).expect("should be a base URL")), + Some(PropertyWithMetadata::Value(PropertyValueWithMetadata { + value: PropertyValue::Object(object), + metadata, + })) if object.is_empty() + && metadata.data_type_id.as_ref().map(ToString::to_string).as_deref() == Some(OBJECT) + ); + } + + #[test] + fn from_stored_round_trip() { + let given = json!({ + NAMES: [{ "dataTypeId": TEXT }, { "dataTypeId": TEXT }], + ADDRESS: { CITY: { "dataTypeId": TEXT } }, + }); + let (_, stored) = join(given.clone()) + .expect("should fit the properties") + .into_parts(); + + assert_eq!( + serde_json::to_value(PropertyObjectMetadata::from_stored(stored)) + .expect("should serialize the metadata"), + given, + "every data type should come back at its position" + ); + } + + #[test] + fn from_stored_list_missing_data_type() { + let stored: stored::PropertyObjectMetadata = serde_json::from_value(json!({ "value": { + NAMES: { "value": [ + { "metadata": { "dataTypeId": TEXT } }, + { "metadata": { "dataTypeId": null } }, + ] }, + ADDRESS: { "value": { CITY: { "metadata": { "dataTypeId": TEXT } } } }, + } })) + .expect("should be stored metadata"); + + assert_eq!( + serde_json::to_value(PropertyObjectMetadata::from_stored(stored)) + .expect("should serialize the metadata"), + json!({ ADDRESS: { CITY: { "dataTypeId": TEXT } } }), + "a list with a value without data type should be left out" + ); + } +} diff --git a/libs/@local/graph/api/src/rest/routing.rs b/libs/@local/graph/api/src/rest/routing.rs index 8efcf819cda..71a015d01e2 100644 --- a/libs/@local/graph/api/src/rest/routing.rs +++ b/libs/@local/graph/api/src/rest/routing.rs @@ -85,10 +85,6 @@ where /// # Panics /// /// Panics if `transform` leaves the operation without an `operationId` or a `summary`. -#[cfg_attr( - not(test), - expect(dead_code, reason = "only the tests route `POST` requests") -)] pub(in crate::rest) fn post( handler: H, transform: impl FnOnce(TransformOperation<'_>) -> TransformOperation<'_>, diff --git a/libs/@local/graph/api/tests/snapshots/openapi/entities-v1.snap.json b/libs/@local/graph/api/tests/snapshots/openapi/entities-v1.snap.json index 311681772c5..79d867f23fc 100644 --- a/libs/@local/graph/api/tests/snapshots/openapi/entities-v1.snap.json +++ b/libs/@local/graph/api/tests/snapshots/openapi/entities-v1.snap.json @@ -2,12 +2,118 @@ "components": { "responses": { "BadRequest": { + "content": { + "application/problem+json": { + "examples": { + "about:blank": { + "summary": "Bad Request: The request body is not valid JSON.", + "value": { + "detail": "Failed to parse the request body as JSON: EOF while parsing an object at line 1 column 1", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + } + }, + "about:blank (2)": { + "summary": "Bad Request: The request body could not be read to its end.", + "value": { + "detail": "Failed to buffer the request body: error reading a body from connection", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + } + } + }, + "schema": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 400 + }, + "title": { + "const": "Bad Request" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Bad Request: The request body is not valid JSON." + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 400 + }, + "title": { + "const": "Bad Request" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Bad Request: The request body could not be read to its end." + } + ] + } + } + }, + "description": "- Bad Request: The request body is not valid JSON.\n- Bad Request: The request body could not be read to its end.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + }, + "Forbidden": { "content": { "application/problem+json": { "example": { - "detail": "Invalid URL: Cannot parse `limit` with value `many` to a `u8`", - "status": 400, - "title": "Bad Request", + "detail": "The caller may not create the entities.", + "status": 403, + "title": "Forbidden", "type": "about:blank" }, "schema": { @@ -18,10 +124,10 @@ { "properties": { "status": { - "const": 400 + "const": 403 }, "title": { - "const": "Bad Request" + "const": "Forbidden" }, "type": { "const": "about:blank" @@ -32,11 +138,11 @@ ] } ], - "title": "Bad Request" + "title": "Forbidden" } } }, - "description": "A path parameter does not match its documented schema.", + "description": "The caller may not create an entity or instantiate an entity type or a type it inherits from.", "headers": { "Cache-Control": { "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", @@ -193,6 +299,70 @@ } } }, + "PayloadTooLarge": { + "content": { + "application/problem+json": { + "example": { + "detail": "Failed to buffer the request body: length limit exceeded", + "status": 413, + "title": "Content Too Large", + "type": "about:blank" + }, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 413 + }, + "title": { + "const": "Content Too Large" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Content Too Large" + } + } + }, + "description": "The request body is larger than the operation accepts.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + }, "ServiceUnavailable": { "content": { "application/problem+json": { @@ -444,96 +614,570 @@ "style": "simple" } } - } - }, - "schemas": { - "Entity": { - "description": "An entity the caller may view.", - "properties": { - "metadata": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityMetadata" - } - ], - "description": "What identifies and classifies the entity." - } - }, - "required": [ - "metadata" - ], - "type": "object" - }, - "EntityId": { - "description": "An entity's web ID and UUID, joined by `~`.", - "examples": [ - "8d86c8c3-d66d-43d1-859b-676c3bcaeadc~be2c5b2f-e3e1-4f26-a0c4-0f8b5e9c4a1d" - ], - "type": "string" }, - "EntityMetadata": { - "description": "What identifies and classifies an entity.", - "properties": { - "entityId": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityId" + "UnprocessableEntity": { + "content": { + "application/problem+json": { + "examples": { + "about:blank": { + "summary": "Unprocessable Content: The request body does not match the schema of the operation.", + "value": { + "detail": "Failed to deserialize the JSON body into the target type: invalid type: string \"many\", expected u8 at line 1 column 6", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank" + } + }, + "about:blank (2)": { + "summary": "Unprocessable Content: One or more entity types do not exist.", + "value": { + "detail": "One or more entity types do not exist.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank" + } + }, + "about:blank (3)": { + "summary": "Unprocessable Content: One or more entity types are archived.", + "value": { + "detail": "One or more entity types are archived.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank" + } + }, + "about:blank (4)": { + "summary": "Unprocessable Content: The entity types of an entity define a property differently.", + "value": { + "detail": "The entity types of an entity define a property differently.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank" + } + }, + "about:blank (5)": { + "summary": "Unprocessable Content: One or more entities do not match their entity types.", + "value": { + "detail": "One or more entities do not match their entity types.", + "status": 422, + "title": "Unprocessable Content", + "type": "about:blank" + } } - ], - "description": "The ID of the entity." - }, - "entityTypeIds": { - "description": "The entity types the entity is an instance of, without the types they inherit from.", - "items": { - "$ref": "#/components/schemas/VersionedUrl" }, - "type": "array", - "uniqueItems": true - } - }, - "required": [ - "entityId", - "entityTypeIds" - ], - "type": "object" - }, - "ProblemDetails": { - "properties": { - "detail": { - "description": "A human-readable explanation of this occurrence that helps the client correct the problem.", - "type": "string" - }, - "instance": { - "description": "A URI reference identifying this occurrence.", - "format": "uri-reference", - "type": "string" - }, - "status": { - "description": "The HTTP status code sent with this occurrence.", - "type": "integer" - }, - "title": { - "description": "A short, human-readable summary of the problem type.", - "type": "string" - }, - "type": { - "default": "about:blank", - "description": "A URI reference identifying the problem type.", - "format": "uri-reference", - "type": "string" - } - }, - "required": [ - "title", - "status" - ], - "title": "Problem Details", - "type": "object" - }, - "VersionedUrl": { - "description": "The versioned URL of a SemType ontology type (the $id of the schema). It must be of the form `${baseUrl}v/${version}`, where `version` is a major version number.", - "examples": [ - "https://hash.ai/@h/types/entity-type/user/v/1" + "schema": { + "anyOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 422 + }, + "title": { + "const": "Unprocessable Content" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unprocessable Content: The request body does not match the schema of the operation." + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 422 + }, + "title": { + "const": "Unprocessable Content" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unprocessable Content: One or more entity types do not exist." + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 422 + }, + "title": { + "const": "Unprocessable Content" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unprocessable Content: One or more entity types are archived." + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 422 + }, + "title": { + "const": "Unprocessable Content" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unprocessable Content: The entity types of an entity define a property differently." + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 422 + }, + "title": { + "const": "Unprocessable Content" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unprocessable Content: One or more entities do not match their entity types." + } + ] + } + } + }, + "description": "- Unprocessable Content: The request body does not match the schema of the operation.\n- Unprocessable Content: One or more entity types do not exist.\n- Unprocessable Content: One or more entity types are archived.\n- Unprocessable Content: The entity types of an entity define a property differently.\n- Unprocessable Content: One or more entities do not match their entity types.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + }, + "UnsupportedMediaType": { + "content": { + "application/problem+json": { + "example": { + "detail": "Expected request with `Content-Type: application/json`", + "status": 415, + "title": "Unsupported Media Type", + "type": "about:blank" + }, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 415 + }, + "title": { + "const": "Unsupported Media Type" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Unsupported Media Type" + } + } + }, + "description": "The request's `Content-Type` is missing or neither `application/json` nor `application/*+json`.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + } + }, + "schemas": { + "BaseUrl": { + "description": "The base URL of a SemType ontology type (the $id of the schema without the versioned suffix). It must end with a trailing slash.", + "format": "uri", + "maxLength": 2048, + "pattern": "/$", + "title": "Base URL", + "type": "string" + }, + "CreateEntitiesRequest": { + "additionalProperties": false, + "description": "The entities to create.", + "properties": { + "entities": { + "description": "The entities to create, in one transaction, at least one.", + "items": { + "$ref": "#/components/schemas/NewEntity" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "entities" + ], + "type": "object" + }, + "CreateEntitiesResponse": { + "description": "The created entities.", + "properties": { + "entities": { + "description": "The created entities, in the order of the request.", + "items": { + "$ref": "#/components/schemas/Entity" + }, + "type": "array" + } + }, + "required": [ + "entities" + ], + "type": "object" + }, + "Entity": { + "description": "An entity the caller may view.", + "properties": { + "metadata": { + "allOf": [ + { + "$ref": "#/components/schemas/EntityMetadata" + } + ], + "description": "What identifies and classifies the entity." + }, + "properties": { + "allOf": [ + { + "$ref": "#/components/schemas/PropertyObject" + } + ], + "description": "The entity's properties, keyed by the base URL of their property type." + }, + "propertyMetadata": { + "allOf": [ + { + "$ref": "#/components/schemas/PropertyObjectMetadata" + } + ], + "description": "The data types of the values in `properties`." + } + }, + "required": [ + "metadata", + "properties", + "propertyMetadata" + ], + "type": "object" + }, + "EntityId": { + "description": "An entity's web ID and UUID, joined by `~`.", + "examples": [ + "8d86c8c3-d66d-43d1-859b-676c3bcaeadc~be2c5b2f-e3e1-4f26-a0c4-0f8b5e9c4a1d" + ], + "type": "string" + }, + "EntityMetadata": { + "description": "What identifies and classifies an entity.", + "properties": { + "entityId": { + "allOf": [ + { + "$ref": "#/components/schemas/EntityId" + } + ], + "description": "The ID of the entity." + }, + "entityTypeIds": { + "description": "The entity types the entity is an instance of, without the types they inherit from.", + "items": { + "$ref": "#/components/schemas/VersionedUrl" + }, + "type": "array", + "uniqueItems": true + } + }, + "required": [ + "entityId", + "entityTypeIds" + ], + "type": "object" + }, + "NewEntity": { + "additionalProperties": false, + "description": "An entity to create.", + "properties": { + "entityTypeIds": { + "description": "The entity types the entity is an instance of, at least one.", + "items": { + "$ref": "#/components/schemas/VersionedUrl" + }, + "minItems": 1, + "type": "array", + "uniqueItems": true + }, + "properties": { + "allOf": [ + { + "$ref": "#/components/schemas/PropertyObject" + } + ], + "description": "The entity's properties, keyed by the base URL of their property type." + }, + "propertyMetadata": { + "allOf": [ + { + "$ref": "#/components/schemas/PropertyObjectMetadata" + } + ], + "description": "The data types of the values in `properties`, one for every value." + }, + "webId": { + "description": "The web to create the entity in.", + "examples": [ + "8d86c8c3-d66d-43d1-859b-676c3bcaeadc" + ], + "format": "uuid", + "type": "string" + } + }, + "required": [ + "webId", + "entityTypeIds", + "properties", + "propertyMetadata" + ], + "type": "object" + }, + "ProblemDetails": { + "properties": { + "detail": { + "description": "A human-readable explanation of this occurrence that helps the client correct the problem.", + "type": "string" + }, + "instance": { + "description": "A URI reference identifying this occurrence.", + "format": "uri-reference", + "type": "string" + }, + "status": { + "description": "The HTTP status code sent with this occurrence.", + "type": "integer" + }, + "title": { + "description": "A short, human-readable summary of the problem type.", + "type": "string" + }, + "type": { + "default": "about:blank", + "description": "A URI reference identifying the problem type.", + "format": "uri-reference", + "type": "string" + } + }, + "required": [ + "title", + "status" + ], + "title": "Problem Details", + "type": "object" + }, + "Property": { + "anyOf": [ + { + "$ref": "#/components/schemas/PropertyArray" + }, + { + "$ref": "#/components/schemas/PropertyObject" + }, + { + "$ref": "#/components/schemas/PropertyValue" + } + ], + "description": "A list of properties, properties keyed by the base URL of their property type, or a value of a data type." + }, + "PropertyArray": { + "description": "A list of properties.", + "items": { + "$ref": "#/components/schemas/Property" + }, + "type": "array" + }, + "PropertyArrayMetadata": { + "description": "The metadata of a list of properties, one entry per property in the same order.", + "items": { + "$ref": "#/components/schemas/PropertyMetadata" + }, + "type": "array" + }, + "PropertyMetadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/PropertyArrayMetadata" + }, + { + "$ref": "#/components/schemas/PropertyObjectMetadata" + }, + { + "$ref": "#/components/schemas/PropertyValueMetadata" + } + ], + "description": "The metadata of a property: of a list, of properties keyed by base URL, or of a value." + }, + "PropertyObject": { + "additionalProperties": { + "$ref": "#/components/schemas/Property" + }, + "description": "Properties keyed by the base URL of their property type.", + "examples": [ + { + "https://hash.ai/@h/types/property-type/preferred-name/": "Alice", + "https://hash.ai/@h/types/property-type/shortname/": "alice" + } + ], + "propertyNames": { + "$ref": "#/components/schemas/BaseUrl" + }, + "type": "object" + }, + "PropertyObjectMetadata": { + "additionalProperties": { + "$ref": "#/components/schemas/PropertyMetadata" + }, + "description": "The metadata of properties, keyed by the base URL of their property type.", + "examples": [ + { + "https://hash.ai/@h/types/property-type/preferred-name/": { + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + }, + "https://hash.ai/@h/types/property-type/shortname/": { + "dataTypeId": "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1" + } + } + ], + "propertyNames": { + "$ref": "#/components/schemas/BaseUrl" + }, + "type": "object" + }, + "PropertyValue": { + "description": "A value of a data type: `null`, a boolean, a string, a number, an array or an object." + }, + "PropertyValueMetadata": { + "additionalProperties": false, + "description": "The metadata of a property value.", + "properties": { + "dataTypeId": { + "allOf": [ + { + "$ref": "#/components/schemas/VersionedUrl" + } + ], + "description": "The data type of the value." + } + }, + "required": [ + "dataTypeId" + ], + "type": "object" + }, + "VersionedUrl": { + "description": "The versioned URL of a SemType ontology type (the $id of the schema). It must be of the form `${baseUrl}v/${version}`, where `version` is a major version number.", + "examples": [ + "https://hash.ai/@h/types/entity-type/user/v/1" ], "format": "uri", "maxLength": 2048, @@ -556,9 +1200,100 @@ }, "openapi": "3.1.0", "paths": { + "/entities/v1": { + "post": { + "description": "Creates the entities in one transaction: either all of them are created or none. Returns them in the order of the request.", + "operationId": "createEntities", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEntitiesRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateEntitiesResponse" + } + } + }, + "description": "The created entities.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "422": { + "$ref": "#/components/responses/UnprocessableEntity" + }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, + "500": { + "$ref": "#/components/responses/InternalServerError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + }, + "security": [ + { + "apiToken": [] + } + ], + "summary": "Create entities", + "tags": [ + "Entity" + ] + } + }, "/entities/v1/{entityId}": { "get": { - "description": "Returns the entity's ID and the entity types of its current edition.", + "description": "Returns the entity's ID, the entity types, and the properties with the data types of their values, of its current edition. `propertyMetadata` leaves out a value without a stored data type, and a list that contains one.", "operationId": "getEntity", "parameters": [ { @@ -612,7 +1347,68 @@ } }, "400": { - "$ref": "#/components/responses/BadRequest" + "content": { + "application/problem+json": { + "example": { + "detail": "Invalid URL: Cannot parse `limit` with value `many` to a `u8`", + "status": 400, + "title": "Bad Request", + "type": "about:blank" + }, + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ProblemDetails" + }, + { + "properties": { + "status": { + "const": 400 + }, + "title": { + "const": "Bad Request" + }, + "type": { + "const": "about:blank" + } + }, + "required": [ + "type" + ] + } + ], + "title": "Bad Request" + } + } + }, + "description": "A path parameter does not match its documented schema.", + "headers": { + "Cache-Control": { + "description": "Always `private, no-store`: the response depends on the caller, so no cache stores it.", + "example": "private, no-store", + "required": true, + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit": { + "description": "The requests each budget in `RateLimit-Policy` has left after this request, as its `r`. A crossed budget reports `r=0`.", + "example": "\"actor\";r=499", + "schema": { + "type": "string" + }, + "style": "simple" + }, + "RateLimit-Policy": { + "description": "The budgets the request was checked against, one item each, as draft-ietf-httpapi-ratelimit-headers-11 defines them. `q` is the burst a budget admits at once, and `w` the whole seconds an exhausted budget takes to refill, so `q` requests per `w` seconds stay within its sustained rate.", + "example": "\"actor\";q=500;w=50", + "schema": { + "type": "string" + }, + "style": "simple" + } + } }, "401": { "$ref": "#/components/responses/Unauthorized" diff --git a/libs/@local/graph/authorization/rust/src/policies/components.rs b/libs/@local/graph/authorization/rust/src/policies/components.rs index a89ae96829b..547eaabe383 100644 --- a/libs/@local/graph/authorization/rust/src/policies/components.rs +++ b/libs/@local/graph/authorization/rust/src/policies/components.rs @@ -22,7 +22,10 @@ use super::{ EntityTypeResource, EntityTypeResourceConstraint, PolicyMetaResource, PropertyTypeResource, PropertyTypeResourceConstraint, ResourceConstraint, }, - store::{PolicyStore, PrincipalStore, ResolvePoliciesParams, error::ContextCreationError}, + store::{ + PolicyStore, PrincipalStore, ResolvePoliciesParams, + error::{BuildEntityTypeContextError, ContextCreationError}, + }, }; /// Optimization data extracted from policy analysis for database entity filter generation. @@ -560,6 +563,21 @@ impl<'a, S> PolicyComponentsBuilder<'a, S> { } } +/// Returns the entity types `report` names as missing, or [`None`] if it reports any other error. +fn missing_entity_types( + report: &Report<[BuildEntityTypeContextError]>, +) -> Option> { + report + .current_contexts() + .map(|context| match context { + BuildEntityTypeContextError::EntityTypeNotFound { entity_type_id } => { + Some(entity_type_id.clone()) + } + BuildEntityTypeContextError::StoreError => None, + }) + .collect() +} + impl IntoFuture for PolicyComponentsBuilder<'_, S> where S: PrincipalStore + PolicyStore + Sync, @@ -608,8 +626,16 @@ where .store .build_entity_type_context(&entity_type_ids) .await - .change_context_lazy(|| ContextCreationError::BuildEntityTypeContext { - entity_type_ids: entity_type_ids.into_iter().cloned().collect(), + .map_err(|report| { + let context = missing_entity_types(&report).map_or_else( + || ContextCreationError::BuildEntityTypeContext { + entity_type_ids: entity_type_ids.into_iter().cloned().collect(), + }, + |entity_type_ids| ContextCreationError::EntityTypesNotFound { + entity_type_ids, + }, + ); + report.change_context(context) })?; for entity_type_resource in &entity_type_resources { self.context.add_entity_type(entity_type_resource); diff --git a/libs/@local/graph/authorization/rust/src/policies/store/error.rs b/libs/@local/graph/authorization/rust/src/policies/store/error.rs index c1f68a9ceef..1e2a4b65359 100644 --- a/libs/@local/graph/authorization/rust/src/policies/store/error.rs +++ b/libs/@local/graph/authorization/rust/src/policies/store/error.rs @@ -230,6 +230,11 @@ pub enum ContextCreationError { BuildEntityTypeContext { entity_type_ids: HashSet, }, + #[display("Entity types with IDs `{}` do not exist", + entity_type_ids.iter().map(VersionedUrl::to_string).collect::>().join(", "))] + EntityTypesNotFound { + entity_type_ids: HashSet, + }, #[display("Could not build property type context for property types with IDs `{}`", property_type_ids.iter().map(VersionedUrl::to_string).collect::>().join(", "))] BuildPropertyTypeContext { diff --git a/libs/@local/graph/postgres-store/src/lib.rs b/libs/@local/graph/postgres-store/src/lib.rs index 1f0884d9892..693845135ad 100644 --- a/libs/@local/graph/postgres-store/src/lib.rs +++ b/libs/@local/graph/postgres-store/src/lib.rs @@ -12,7 +12,6 @@ const_closures, const_trait_impl, extend_one, - iter_intersperse, variant_count )] #![cfg_attr(not(miri), doc(test(attr(deny(warnings, clippy::all)))))] diff --git a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs index 231abc0610d..2ed8d2ec578 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/knowledge/entity/mod.rs @@ -12,13 +12,13 @@ use alloc::borrow::Cow; use core::{any::Any, borrow::Borrow as _, fmt, mem}; use std::collections::{HashMap, HashSet}; -use error_stack::{FutureExt as _, Report, ResultExt as _, TryReportStreamExt as _, ensure}; +use error_stack::{FutureExt as _, Report, ResultExt as _, TryReportStreamExt as _}; use futures::{StreamExt as _, TryStreamExt as _, stream}; use hash_graph_authorization::policies::{ - Authorized, MergePolicies, PolicyComponents, Request, RequestContext, ResourceId, + Authorized, MergePolicies, PolicyComponents, PolicySet, Request, RequestContext, ResourceId, action::ActionName, resource::{EntityResourceConstraint, ResourceConstraint}, - store::PolicyCreationParams, + store::{PolicyCreationParams, PolicyStore, PrincipalStore, error::ContextCreationError}, }; use hash_graph_embeddings::{Dimension, clustering::Clustering}; use hash_graph_store::{ @@ -35,7 +35,8 @@ use hash_graph_store::{ }, entity_type::{EntityTypeStore as _, IncludeEntityTypeOption}, error::{ - CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + CheckPermissionError, ClusterError, DeletionError, EntityCreationError, InsertionError, + QueryError, UpdateError, }, filter::{Filter, FilterExpression, FilterExpressionList, ParameterList}, query::QueryResult as _, @@ -86,7 +87,10 @@ use type_system::{ ontology::{ InheritanceDepth, data_type::schema::DataTypeReference, - entity_type::{ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid}, + entity_type::{ + ClosedEntityType, ClosedMultiEntityType, EntityTypeUuid, + schema::ResolveClosedEntityTypeError, + }, id::{OntologyTypeUuid, VersionedUrl}, }, principal::{ @@ -1185,7 +1189,7 @@ where &mut self, actor_id: ActorId, params: Vec, - ) -> Result, Report> { + ) -> Result, Report> { let transaction_time = Timestamp::::now().remove_nanosecond(); let mut entity_edition_ids = Vec::with_capacity(params.len()); @@ -1206,7 +1210,7 @@ where let transaction = self .begin_transaction() .await - .change_context(InsertionError)?; + .change_context(EntityCreationError::Store)?; let mut policy_components_builder = PolicyComponents::builder(&transaction, Some(actor_id)); @@ -1236,27 +1240,96 @@ where // The policy components builder will make sure, that also parent entity types are added to // the set of entity type IDs. These are accessible via `tracked_entity_types` method. - let policy_components = policy_components_builder - .with_entity_type_ids(entity_type_id_set) + let policy_components = match policy_components_builder + .with_entity_type_ids(entity_type_id_set.iter().copied()) .with_actions( - [ActionName::Instantiate, ActionName::CreateEntity], + [ + ActionName::Instantiate, + ActionName::CreateEntity, + ActionName::ViewEntityType, + ], MergePolicies::No, ) .with_actions( [ ActionName::ViewEntity, - ActionName::ViewEntityType, ActionName::ViewPropertyType, ActionName::ViewDataType, ], MergePolicies::Yes, ) .await - .change_context(InsertionError)?; + { + Ok(policy_components) => policy_components, + Err(report) => { + return Err(entity_creation_error( + &transaction, + actor_id, + &entity_type_id_set, + report, + ) + .await); + } + }; let policy_set = policy_components - .build_policy_set([ActionName::Instantiate, ActionName::CreateEntity]) - .change_context(InsertionError)?; + .build_policy_set([ + ActionName::Instantiate, + ActionName::CreateEntity, + ActionName::ViewEntityType, + ]) + .change_context(EntityCreationError::Store)?; + + // An entity type the actor may not view is reported like one that does not exist, also by + // the legacy API's status code. This runs before the instantiation check, whose answer + // would reveal that the entity type exists. + let invisible_entity_types = invisible_entity_types( + &policy_components, + &policy_set, + entity_type_id_set.iter().copied(), + )?; + if !invisible_entity_types.is_empty() { + return Err(Report::new(EntityCreationError::UnknownEntityTypes { + entity_type_ids: invisible_entity_types, + }) + .attach_opaque(StatusCode::NotFound)); + } + + let requested_entity_types = entity_type_id_set.iter().copied().collect::>(); + let archived_entity_types = transaction + .as_client() + .query( + " + SELECT input.url + FROM unnest($1::text[]) AS input(url) + WHERE NOT EXISTS ( + SELECT 1 + FROM entity_types + JOIN ontology_temporal_metadata USING (ontology_id) + WHERE entity_types.schema ->> '$id' = input.url + AND ontology_temporal_metadata.transaction_time @> now() + ) + ", + &[&requested_entity_types], + ) + .instrument(tracing::info_span!( + "SELECT", + otel.kind = "client", + db.system = "postgresql", + peer.service = "Postgres", + )) + .await + .change_context(EntityCreationError::Store)? + .into_iter() + .map(|row| row.get::<_, VersionedUrl>(0)) + .collect::>(); + + if !archived_entity_types.is_empty() { + return Err(Report::new(EntityCreationError::ArchivedEntityTypes { + entity_type_ids: archived_entity_types, + }) + .attach_opaque(StatusCode::InvalidArgument)); + } let mut forbidden_instantiations = Vec::new(); for entity_type_id in policy_components.tracked_entity_types() { @@ -1270,7 +1343,7 @@ where }, policy_components.context(), ) - .change_context(InsertionError)? + .change_context(EntityCreationError::Store)? { Authorized::Always => {} Authorized::Never => { @@ -1280,19 +1353,10 @@ where } if !forbidden_instantiations.is_empty() { - return Err(Report::new(InsertionError) - .attach_opaque(StatusCode::PermissionDenied) - .attach( - "The actor does not have permission to instantiate one or more entity types", - ) - .attach( - forbidden_instantiations - .into_iter() - .map(ToString::to_string) - .map(Cow::Owned) - .intersperse(Cow::Borrowed(", ")) - .collect::(), - )); + return Err(Report::new(EntityCreationError::InstantiationForbidden { + entity_type_ids: forbidden_instantiations.into_iter().cloned().collect(), + }) + .attach_opaque(StatusCode::PermissionDenied)); } let mut forbidden_entity_creations = Vec::new(); @@ -1307,26 +1371,20 @@ where }, policy_components.context(), ) - .change_context(InsertionError)? + .change_context(EntityCreationError::Store)? { Authorized::Always => {} Authorized::Never => { - forbidden_entity_creations.push(entity_id); + forbidden_entity_creations.push(*entity_id); } } } if !forbidden_entity_creations.is_empty() { - return Err(Report::new(InsertionError) - .attach_opaque(StatusCode::PermissionDenied) - .attach("The actor does not have permission to create one or more entities") - .attach( - forbidden_entity_creations - .into_iter() - .map(ToString::to_string) - .collect::>() - .join(", "), - )); + return Err(Report::new(EntityCreationError::CreationForbidden { + entity_ids: forbidden_entity_creations, + }) + .attach_opaque(StatusCode::PermissionDenied)); } let validator_provider = StoreProvider::new(&transaction, &policy_components); @@ -1353,9 +1411,23 @@ where }) .try_collect::>() .await - .change_context(InsertionError)?, + .change_context(EntityCreationError::Store)?, ) - .change_context(InsertionError)?; + .map_err(|report| match report.current_context() { + ResolveClosedEntityTypeError::IncompatibleProperty(property_type) => { + let context = EntityCreationError::IncompatibleEntityTypes { + property_type: property_type.clone(), + }; + report + .change_context(context) + .attach_opaque(StatusCode::InvalidArgument) + } + ResolveClosedEntityTypeError::UnknownSchemas(_) + | ResolveClosedEntityTypeError::EmptySchema + | ResolveClosedEntityTypeError::InheritanceDepthOverflow => { + report.change_context(EntityCreationError::Store) + } + })?; let mut preprocessor = EntityPreprocessor { components: if params.draft { @@ -1595,9 +1667,9 @@ where db.query.text = statement, )) .await - .change_context(InsertionError)?; + .change_context(EntityCreationError::Store)?; if inserted_rows != *expected_rows as u64 { - return Err(Report::new(InsertionError).attach(format!( + return Err(Report::new(EntityCreationError::Store).attach(format!( "bulk insert into `{table}` affected {inserted_rows} rows but {expected_rows} \ were provided", table = table.as_str(), @@ -1632,7 +1704,7 @@ where peer.service = "Postgres", )) .await - .change_context(InsertionError)?; + .change_context(EntityCreationError::Store)?; transaction .as_client() @@ -1647,7 +1719,7 @@ where peer.service = "Postgres", )) .await - .change_context(InsertionError)?; + .change_context(EntityCreationError::Store)?; for (index, (entity, (schema, components))) in entities.iter().zip(validation_params).enumerate() @@ -1665,14 +1737,22 @@ where transaction .insert_policies_into_database(policies) .await - .change_context(InsertionError)?; + .change_context(EntityCreationError::Store)?; - ensure!( - validation_reports.is_empty(), - Report::new(InsertionError).attach_opaque(validation_reports) - ); + if !validation_reports.is_empty() { + tracing::info!( + ?validation_reports, + "the entities do not match their entity types" + ); + return Err( + Report::new(EntityCreationError::InvalidEntities).attach_opaque(validation_reports) + ); + } - transaction.commit().await.change_context(InsertionError)?; + transaction + .commit() + .await + .change_context(EntityCreationError::Store)?; if !self.settings.skip_embedding_creation && let Some(temporal_client) = &self.temporal_client @@ -1681,14 +1761,22 @@ where .iter() .map(|entity| entity.metadata.record_id.entity_id) .collect(); - temporal_client + // The entities are committed at this point. Failing the request would make the caller + // create them again. + if let Err(report) = temporal_client .start_update_entity_embeddings_workflow( ActorEntityUuid::from(actor_id), &entity_ids, self.settings.filter_protection.embedding_exclusions(), ) .await - .change_context(InsertionError)?; + { + tracing::warn!( + error = ?report, + entity_count = entity_ids.len(), + "could not start the embedding workflow for the created entities" + ); + } } Ok(entities) @@ -2485,6 +2573,107 @@ struct LockedEntityEdition { transaction_time: LeftClosedTemporalInterval, } +/// The entity types among `entity_type_ids` that the actor may not view. +fn invisible_entity_types<'url>( + policy_components: &PolicyComponents, + policy_set: &PolicySet, + entity_type_ids: impl IntoIterator, +) -> Result, Report> { + let mut invisible = HashSet::new(); + for entity_type_id in entity_type_ids { + match policy_set + .evaluate( + &Request { + actor: policy_components.actor_id(), + action: ActionName::ViewEntityType, + resource: &ResourceId::EntityType(Cow::Borrowed(entity_type_id.into())), + context: RequestContext::default(), + }, + policy_components.context(), + ) + .change_context(EntityCreationError::Store)? + { + Authorized::Always => {} + Authorized::Never => { + invisible.insert(entity_type_id.clone()); + } + } + } + Ok(invisible) +} + +/// The `missing` entity types together with the `requested` ones the actor may not view. +async fn unknown_entity_types( + store: &S, + actor_id: ActorId, + requested: &HashSet<&VersionedUrl>, + missing: &HashSet, +) -> Result, Report> +where + S: PrincipalStore + PolicyStore + Sync, +{ + let existing = requested + .iter() + .copied() + .filter(|entity_type_id| !missing.contains(*entity_type_id)) + .collect::>(); + let mut unknown = missing.clone(); + if existing.is_empty() { + return Ok(unknown); + } + + let policy_components = PolicyComponents::builder(store, Some(actor_id)) + .with_entity_type_ids(existing.iter().copied()) + .with_action(ActionName::ViewEntityType, MergePolicies::No) + .await + .change_context(EntityCreationError::Store)?; + let policy_set = policy_components + .build_policy_set([ActionName::ViewEntityType]) + .change_context(EntityCreationError::Store)?; + unknown.extend(invisible_entity_types( + &policy_components, + &policy_set, + existing, + )?); + Ok(unknown) +} + +/// Classifies a failure to resolve the policies for creating entities. +async fn entity_creation_error( + store: &S, + actor_id: ActorId, + requested: &HashSet<&VersionedUrl>, + report: Report, +) -> Report +where + S: PrincipalStore + PolicyStore + Sync, +{ + match report.current_context() { + // The entity types the actor may not view are reported with the missing ones, so the + // answer does not tell them apart. It is a new report, because the legacy API serializes + // the sources of a report. + ContextCreationError::EntityTypesNotFound { entity_type_ids } => { + match unknown_entity_types(store, actor_id, requested, entity_type_ids).await { + Ok(entity_type_ids) => { + Report::new(EntityCreationError::UnknownEntityTypes { entity_type_ids }) + .attach_opaque(StatusCode::NotFound) + } + Err(report) => report, + } + } + ContextCreationError::ActorNotFound { .. } + | ContextCreationError::BuildPrincipalContext { .. } + | ContextCreationError::BuildEntityTypeContext { .. } + | ContextCreationError::BuildPropertyTypeContext { .. } + | ContextCreationError::BuildDataTypeContext { .. } + | ContextCreationError::BuildEntityContext { .. } + | ContextCreationError::ResolveActorPolicies { .. } + | ContextCreationError::CreatePolicySet + | ContextCreationError::CreatePolicyContext + | ContextCreationError::StoreError => report.change_context(EntityCreationError::Store), + } +} + /// Builds the statement populating `entity_edition_cache` by aggregating the editions' /// `entity_is_of_type` rows joined to the referenced types. /// diff --git a/libs/@local/graph/store/rust/src/entity/store.rs b/libs/@local/graph/store/rust/src/entity/store.rs index a8159ce323a..76bd5262d09 100644 --- a/libs/@local/graph/store/rust/src/entity/store.rs +++ b/libs/@local/graph/store/rust/src/entity/store.rs @@ -42,7 +42,8 @@ use crate::{ }, entity_type::{EntityTypeResolveDefinitions, IncludeEntityTypeOption}, error::{ - CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + CheckPermissionError, ClusterError, DeletionError, EntityCreationError, QueryError, + UpdateError, }, filter::{Filter, SemanticDistance}, subgraph::{ @@ -757,20 +758,14 @@ pub struct DeletionSummary { pub trait EntityStore { /// Creates a new [`Entity`]. /// - /// # Errors: - /// - /// - if the [`EntityType`] doesn't exist - /// - if the [`PropertyObjectWithMetadata`] is not valid with respect to the specified - /// [`EntityType`] - /// - if the account referred to by `web_id` does not exist - /// - if an [`EntityUuid`] was supplied and already exists in the store + /// # Errors /// - /// [`EntityType`]: type_system::ontology::entity_type::EntityType + /// The same as [`create_entities`](Self::create_entities). fn create_entity( &mut self, actor_id: ActorId, params: CreateEntityParams, - ) -> impl Future>> + Send { + ) -> impl Future>> + Send { self.create_entities(actor_id, vec![params]) .map_ok(|mut entities| { let entity = entities.pop().expect("Expected a single entity"); @@ -779,12 +774,33 @@ pub trait EntityStore { }) } - /// Creates new [`Entities`][Entity]. + /// Creates new [`Entities`][Entity], either all of them or none. + /// + /// # Errors + /// + /// - [`UnknownEntityTypes`] if one of the entity types does not exist or the actor may not view + /// it + /// - [`ArchivedEntityTypes`] if one of the entity types is archived + /// - [`InstantiationForbidden`] if the actor may not instantiate one of the entity types or a + /// type it inherits from + /// - [`CreationForbidden`] if the actor may not create one of the entities + /// - [`IncompatibleEntityTypes`] if the entity types of an entity define a property differently + /// - [`InvalidEntities`] if an entity does not match its entity types + /// - [`Store`] if the store cannot create the entities, including when an entity type from + /// another domain cannot be fetched + /// + /// [`UnknownEntityTypes`]: EntityCreationError::UnknownEntityTypes + /// [`ArchivedEntityTypes`]: EntityCreationError::ArchivedEntityTypes + /// [`InstantiationForbidden`]: EntityCreationError::InstantiationForbidden + /// [`CreationForbidden`]: EntityCreationError::CreationForbidden + /// [`IncompatibleEntityTypes`]: EntityCreationError::IncompatibleEntityTypes + /// [`InvalidEntities`]: EntityCreationError::InvalidEntities + /// [`Store`]: EntityCreationError::Store fn create_entities( &mut self, actor_id: ActorId, params: Vec, - ) -> impl Future, Report>> + Send; + ) -> impl Future, Report>> + Send; /// Validates an [`Entity`]. /// diff --git a/libs/@local/graph/store/rust/src/error.rs b/libs/@local/graph/store/rust/src/error.rs index 546e8db5e09..face4a74596 100644 --- a/libs/@local/graph/store/rust/src/error.rs +++ b/libs/@local/graph/store/rust/src/error.rs @@ -1,4 +1,10 @@ use core::{error::Error, fmt, num::NonZero}; +use std::collections::HashSet; + +use type_system::{ + knowledge::entity::EntityId, + ontology::{BaseUrl, VersionedUrl}, +}; #[derive(Debug)] #[must_use] @@ -36,6 +42,38 @@ impl fmt::Display for UpdateError { impl Error for UpdateError {} +#[derive(Debug, derive_more::Display)] +#[display("Could not create the entities: {_variant}")] +#[must_use] +pub enum EntityCreationError { + #[display("the actor may not instantiate the entity types `{}`", + entity_type_ids.iter().map(VersionedUrl::to_string).collect::>().join(", "))] + InstantiationForbidden { + entity_type_ids: HashSet, + }, + #[display("the actor may not create the entities `{}`", + entity_ids.iter().map(EntityId::to_string).collect::>().join(", "))] + CreationForbidden { entity_ids: Vec }, + #[display("the entity types `{}` do not exist or the actor may not view them", + entity_type_ids.iter().map(VersionedUrl::to_string).collect::>().join(", "))] + UnknownEntityTypes { + entity_type_ids: HashSet, + }, + #[display("the entity types `{}` are archived", + entity_type_ids.iter().map(VersionedUrl::to_string).collect::>().join(", "))] + ArchivedEntityTypes { + entity_type_ids: HashSet, + }, + #[display("the entity types of an entity define the property `{property_type}` differently")] + IncompatibleEntityTypes { property_type: BaseUrl }, + #[display("the entities do not match their entity types")] + InvalidEntities, + #[display("store operation failed")] + Store, +} + +impl Error for EntityCreationError {} + #[derive(Debug, derive_more::Display)] #[display("Could not delete from the store: {_variant}")] #[must_use] diff --git a/libs/@local/graph/type-fetcher/src/store.rs b/libs/@local/graph/type-fetcher/src/store.rs index d6c82ff7093..301aceebace 100644 --- a/libs/@local/graph/type-fetcher/src/store.rs +++ b/libs/@local/graph/type-fetcher/src/store.rs @@ -55,7 +55,8 @@ use hash_graph_store::{ UnarchiveEntityTypeParams, UpdateEntityTypeEmbeddingParams, UpdateEntityTypesParams, }, error::{ - CheckPermissionError, ClusterError, DeletionError, InsertionError, QueryError, UpdateError, + CheckPermissionError, ClusterError, DeletionError, EntityCreationError, InsertionError, + QueryError, UpdateError, }, filter::{Filter, QueryRecord}, pool::StorePool, @@ -1618,7 +1619,7 @@ where &mut self, actor_id: ActorId, params: Vec, - ) -> Result, Report> { + ) -> Result, Report> { let type_ids = params .iter() .flat_map(|params| ¶ms.entity_type_ids) @@ -1634,7 +1635,8 @@ where ConflictBehavior::Skip, &HashSet::new(), ) - .await?; + .await + .change_context(EntityCreationError::Store)?; } self.store.create_entities(actor_id, params).await diff --git a/libs/problematic/rust/src/aide.rs b/libs/problematic/rust/src/aide.rs index 21c548e23dc..18d2d10628d 100644 --- a/libs/problematic/rust/src/aide.rs +++ b/libs/problematic/rust/src/aide.rs @@ -830,8 +830,8 @@ fn variant_schema(base: &Schema, variant: &Variant, extensions: Schema) -> Schem /// The one branch of the `oneOf` or `anyOf` that `extensions` consists of, or `extensions` itself. /// -/// schemars describes an enum with one variant as a `oneOf` of it. Documentation viewers show the -/// members of such a branch without the members it requires. +/// schemars describes an enum with one variant as a `oneOf` of it, which documentation viewers show +/// as a choice with a single entry. fn only_branch(extensions: Schema) -> Schema { extensions .as_object() diff --git a/tests/graph/integration/postgres/lib.rs b/tests/graph/integration/postgres/lib.rs index 20a8cbe3201..0a0a99e38f9 100644 --- a/tests/graph/integration/postgres/lib.rs +++ b/tests/graph/integration/postgres/lib.rs @@ -63,7 +63,10 @@ use hash_graph_store::{ SearchEntityTypesParams, SearchEntityTypesResponse, UnarchiveEntityTypeParams, UpdateEntityTypeEmbeddingParams, UpdateEntityTypesParams, }, - error::{CheckPermissionError, DeletionError, InsertionError, QueryError, UpdateError}, + error::{ + CheckPermissionError, DeletionError, EntityCreationError, InsertionError, QueryError, + UpdateError, + }, pool::StorePool, property_type::{ ArchivePropertyTypeParams, CountPropertyTypesParams, CreatePropertyTypeParams, @@ -809,7 +812,7 @@ impl EntityStore for DatabaseApi<'_> { &mut self, actor_id: ActorId, params: Vec, - ) -> Result, Report> { + ) -> Result, Report> { self.store.create_entities(actor_id, params).await } diff --git a/tests/graph/integration/postgres/multi_type.rs b/tests/graph/integration/postgres/multi_type.rs index 657f9c918db..d75ff4a8a40 100644 --- a/tests/graph/integration/postgres/multi_type.rs +++ b/tests/graph/integration/postgres/multi_type.rs @@ -1,21 +1,29 @@ use core::{assert_matches, str::FromStr as _}; use std::collections::{HashMap, HashSet}; +use hash_graph_authorization::policies::{ + Effect, + action::ActionName, + resource::{EntityTypeId, EntityTypeResourceConstraint, ResourceConstraint}, + store::PolicyCreationParams, +}; +use hash_graph_postgres_store::store::AsClient as _; use hash_graph_store::{ entity::{ CreateEntityParams, EntityQuerySorting, EntityStore as _, PatchEntityParams, QueryEntitiesParams, SummarizeEntitiesParams, }, - error::InsertionError, + error::EntityCreationError, filter::Filter, subgraph::temporal_axes::QueryTemporalAxesUnresolved, }; use hash_graph_test_data::{data_type, entity, entity_type, property_type}; use pretty_assertions::assert_eq; +use serde_json::json; use type_system::{ knowledge::{ Entity, - entity::provenance::ProvidedEntityEditionProvenance, + entity::{EntityId, id::EntityUuid, provenance::ProvidedEntityEditionProvenance}, property::{PropertyObject, PropertyObjectWithMetadata}, }, ontology::VersionedUrl, @@ -25,6 +33,7 @@ use type_system::{ }, provenance::{OriginProvenance, OriginType}, }; +use uuid::Uuid; use crate::{DatabaseApi, DatabaseTestWrapper}; @@ -102,7 +111,320 @@ async fn empty_entity() { .await .expect_err("created entity with no types") .current_context(), - InsertionError + EntityCreationError::Store + ); +} + +fn entity_params( + web_id: WebId, + entity_type_ids: HashSet, + properties: PropertyObject, +) -> CreateEntityParams { + CreateEntityParams { + web_id, + entity_uuid: None, + decision_time: None, + entity_type_ids, + properties: PropertyObjectWithMetadata::from_parts(properties, None) + .expect("should create a property object with metadata"), + confidence: None, + link_data: None, + draft: false, + policies: Vec::new(), + provenance: ProvidedEntityEditionProvenance { + actor_type: ActorType::User, + origin: OriginProvenance::from_empty_type(OriginType::Api), + sources: Vec::new(), + }, + read_only: false, + } +} + +async fn forbid( + api: &DatabaseApi<'_>, + actions: Vec, + resource: Option, +) { + api.store + .insert_policies_into_database([&PolicyCreationParams { + name: Some("test-forbid".to_owned()), + effect: Effect::Forbid, + principal: None, + actions, + resource, + }]) + .await + .expect("should insert the forbid policy"); +} + +fn person_resource() -> ResourceConstraint { + ResourceConstraint::EntityType(EntityTypeResourceConstraint::Exact { + id: EntityTypeId::new(person_entity_type_id()), + }) +} + +#[tokio::test] +async fn unknown_entity_type() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let unknown_entity_type_id = + VersionedUrl::from_str("https://blockprotocol.org/@alice/types/entity-type/unknown/v/1") + .expect("should be a valid entity type id"); + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id(), unknown_entity_type_id.clone()]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of an unknown entity type") + .current_context(), + EntityCreationError::UnknownEntityTypes { entity_type_ids } + if *entity_type_ids == HashSet::from([unknown_entity_type_id]) + ); +} + +#[tokio::test] +async fn invisible_entity_type() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + // The actor may not instantiate the entity type either, so the error shows that the + // visibility check runs first. + forbid( + &api, + vec![ActionName::ViewEntityType, ActionName::Instantiate], + Some(person_resource()), + ) + .await; + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id(), org_entity_type_id()]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of an entity type the actor may not view") + .current_context(), + EntityCreationError::UnknownEntityTypes { entity_type_ids } + if *entity_type_ids == HashSet::from([person_entity_type_id()]) + ); +} + +#[tokio::test] +async fn unknown_and_invisible_entity_types() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + forbid( + &api, + vec![ActionName::ViewEntityType, ActionName::Instantiate], + Some(person_resource()), + ) + .await; + let unknown_entity_type_id = + VersionedUrl::from_str("https://blockprotocol.org/@alice/types/entity-type/unknown/v/1") + .expect("should be a valid entity type id"); + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([ + person_entity_type_id(), + org_entity_type_id(), + unknown_entity_type_id.clone(), + ]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of an unknown entity type") + .current_context(), + EntityCreationError::UnknownEntityTypes { entity_type_ids } + if *entity_type_ids == HashSet::from([unknown_entity_type_id, person_entity_type_id()]) + ); +} + +#[tokio::test] +async fn archived_entity_type() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + // The test runs in one transaction, in which archiving the entity type would leave it an empty + // transaction time. Its transaction time ends in the past instead. + let person = person_entity_type_id(); + api.store + .as_client() + .execute( + "UPDATE ontology_temporal_metadata + SET transaction_time = tstzrange( + lower(transaction_time) - interval '1 hour', + lower(transaction_time) - interval '1 minute' + ) + WHERE ontology_id = ( + SELECT ontology_id FROM ontology_ids WHERE base_url = $1 AND version = $2 + )", + &[&person.base_url, &person.version], + ) + .await + .expect("should archive the entity type"); + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id(), org_entity_type_id()]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of an archived entity type") + .current_context(), + EntityCreationError::ArchivedEntityTypes { entity_type_ids } + if *entity_type_ids == HashSet::from([person_entity_type_id()]) + ); +} + +#[tokio::test] +async fn instantiation_forbidden() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + forbid(&api, vec![ActionName::Instantiate], Some(person_resource())).await; + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id()]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of an entity type the actor may not instantiate") + .current_context(), + EntityCreationError::InstantiationForbidden { entity_type_ids } + if *entity_type_ids == HashSet::from([person_entity_type_id()]) + ); +} + +#[tokio::test] +async fn creation_forbidden() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + forbid(&api, vec![ActionName::CreateEntity], None).await; + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id()]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity the actor may not create") + .current_context(), + EntityCreationError::CreationForbidden { entity_ids } if entity_ids.len() == 1 + ); +} + +#[tokio::test] +async fn invalid_entities_batch() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = seed(&mut database).await; + + let web_id = WebId::new(api.account_id); + let valid_entity_uuid = EntityUuid::new(Uuid::new_v4()); + let mut valid = entity_params(web_id, HashSet::from([person_entity_type_id()]), alice()); + valid.entity_uuid = Some(valid_entity_uuid); + let invalid = entity_params( + web_id, + HashSet::from([person_entity_type_id()]), + serde_json::from_value(json!({ + "https://blockprotocol.org/@alice/types/property-type/name/": 5, + })) + .expect("should be a property object"), + ); + + assert_matches!( + api.create_entities(api.account_id, vec![valid, invalid]) + .await + .expect_err("should not create entities of which one does not match its entity types") + .current_context(), + EntityCreationError::InvalidEntities + ); + assert!( + api.get_entity_by_id( + Some(api.account_id), + EntityId { + web_id, + entity_uuid: valid_entity_uuid, + draft_id: None, + }, + None, + None, + ) + .await + .expect("should read the store") + .is_none(), + "the valid entity should not be created when another entity of the request is invalid" + ); +} + +#[tokio::test] +async fn incompatible_entity_types() { + let mut database = DatabaseTestWrapper::new().await; + let mut api = database + .seed( + [ + data_type::VALUE_V1, + data_type::TEXT_V1, + data_type::NUMBER_V1, + ], + [ + property_type::NAME_V1, + property_type::AGE_V1, + property_type::TEXT_V1, + property_type::FAVORITE_SONG_V1, + property_type::FAVORITE_FILM_V1, + property_type::HOBBY_V1, + property_type::INTERESTS_V1, + property_type::BLURB_V1, + property_type::PUBLISHED_ON_V1, + ], + [ + entity_type::PERSON_V1, + entity_type::BOOK_V1, + entity_type::LINK_V1, + entity_type::link::FRIEND_OF_V1, + entity_type::link::ACQUAINTANCE_OF_V1, + entity_type::link::WRITTEN_BY_V1, + ], + ) + .await + .expect("should seed the database"); + + // A book has a list of names, a person has one. + let book_entity_type_id = + VersionedUrl::from_str("https://blockprotocol.org/@alice/types/entity-type/book/v/1") + .expect("should be a valid entity type id"); + let params = entity_params( + WebId::new(api.account_id), + HashSet::from([person_entity_type_id(), book_entity_type_id]), + alice(), + ); + + assert_matches!( + api.create_entity(api.account_id, params) + .await + .expect_err("should not create an entity of entity types that define a property differently") + .current_context(), + EntityCreationError::IncompatibleEntityTypes { property_type } + if property_type.as_str() == "https://blockprotocol.org/@alice/types/property-type/name/" ); } diff --git a/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts b/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts index 22bb68aedec..bfbe9588454 100644 --- a/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts +++ b/tests/hash-backend-integration/src/tests/graph/knowledge/primitive/entity.test.ts @@ -591,7 +591,7 @@ describe("Entity CRU", () => { }, entityTypeIds: [systemEntityTypes.actor.entityTypeId], }), - ).rejects.toThrowError(`Could not insert into store`); + ).rejects.toMatchObject({ status: { code: "PERMISSION_DENIED" } }); }); it("Cannot instantiate user entity type", async () => { @@ -632,7 +632,7 @@ describe("Entity CRU", () => { }, entityTypeIds: [systemEntityTypes.user.entityTypeId], }), - ).rejects.toThrowError(`Could not insert into store`); + ).rejects.toMatchObject({ status: { code: "PERMISSION_DENIED" } }); }); it("Cannot instantiate machine entity type", async () => { @@ -670,7 +670,7 @@ describe("Entity CRU", () => { }, entityTypeIds: [systemEntityTypes.machine.entityTypeId], }), - ).rejects.toThrowError(`Could not insert into store`); + ).rejects.toMatchObject({ status: { code: "PERMISSION_DENIED" } }); }); it("Cannot instantiate organization entity type", async () => { @@ -707,7 +707,7 @@ describe("Entity CRU", () => { }, entityTypeIds: [systemEntityTypes.organization.entityTypeId], }), - ).rejects.toThrowError(`Could not insert into store`); + ).rejects.toMatchObject({ status: { code: "PERMISSION_DENIED" } }); }); it("Cannot instantiate hash-instance entity type", async () => { @@ -759,6 +759,6 @@ describe("Entity CRU", () => { }, entityTypeIds: [systemEntityTypes.hashInstance.entityTypeId], }), - ).rejects.toThrowError(`Could not insert into store`); + ).rejects.toMatchObject({ status: { code: "PERMISSION_DENIED" } }); }); });