From 67116523ee8ea92900988aa65145c9ffbbd17be0 Mon Sep 17 00:00:00 2001 From: Alfred Mountfield Date: Thu, 8 Sep 2022 15:11:39 +0100 Subject: [PATCH] Automate Type URI generation (#1039) --- packages/graph/README.md | 5 + packages/graph/clients/typescript/api.ts | 267 ++++++++++++- packages/graph/hash_graph/Cargo.lock | 2 + .../hash_graph/bin/hash_graph/Cargo.toml | 1 + .../hash_graph/bin/hash_graph/src/args.rs | 25 +- .../hash_graph/bin/hash_graph/src/main.rs | 10 +- .../graph/hash_graph/lib/graph/Cargo.toml | 1 + .../lib/graph/src/api/rest/data_type.rs | 50 ++- .../lib/graph/src/api/rest/entity_type.rs | 46 ++- .../rest/json_schemas/update_data_type.json | 14 + .../rest/json_schemas/update_entity_type.json | 157 ++++++++ .../rest/json_schemas/update_link_type.json | 17 + .../json_schemas/update_property_type.json | 150 ++++++++ .../lib/graph/src/api/rest/link_type.rs | 46 ++- .../hash_graph/lib/graph/src/api/rest/mod.rs | 43 ++- .../lib/graph/src/api/rest/property_type.rs | 50 ++- .../graph/src/ontology/domain_validator.rs | 226 +++++++++++ .../hash_graph/lib/graph/src/ontology/mod.rs | 60 ++- .../postgres/ontology/entity_type/mod.rs | 14 +- .../postgres/ontology/property_type/mod.rs | 14 +- .../graph/hash_graph/tests/rest-test.http | 44 +-- packages/hash/api/codegen.yml | 4 + .../hash/api/src/graph/workspace-types.ts | 320 ++++++++++++---- .../graphql/resolvers/ontology/entity-type.ts | 9 +- .../graphql/resolvers/ontology/link-type.ts | 9 +- .../resolvers/ontology/property-type.ts | 9 +- .../typeDefs/ontology/data-type.typedef.ts | 5 +- .../typeDefs/ontology/entity-type.typedef.ts | 5 +- .../typeDefs/ontology/link-type.typedef.ts | 5 +- .../ontology/property-type.typedef.ts | 5 +- packages/hash/api/src/index.ts | 4 +- .../api/src/model/knowledge/account.fields.ts | 33 +- .../hash/api/src/model/knowledge/org.model.ts | 122 ++---- .../api/src/model/knowledge/user.model.ts | 171 ++++----- .../api/src/model/ontology/data-type.model.ts | 77 ++-- .../src/model/ontology/entity-type.model.ts | 72 ++-- .../api/src/model/ontology/link-type.model.ts | 56 ++- .../src/model/ontology/property-type.model.ts | 74 ++-- packages/hash/api/src/model/util.ts | 352 +++++++++++------- packages/hash/backend-utils/src/system.ts | 1 + .../ontology/ontology-types-shim.ts | 12 +- .../queries/ontology/entity-type.queries.ts | 7 +- .../queries/ontology/link-type.queries.ts | 4 +- .../queries/ontology/property-type.queries.ts | 7 +- .../src/pages/type-editor/index.page.tsx | 59 ++- .../model/knowledge/entity.model.test.ts | 79 ++-- .../tests/model/knowledge/link.model.test.ts | 40 +- .../model/ontology/data-type.model.test.ts | 54 +-- .../model/ontology/entity-type.model.test.ts | 137 +++---- .../model/ontology/link-type.model.test.ts | 46 +-- .../ontology/property-type.model.test.ts | 60 ++- packages/hash/integration/src/tests/util.ts | 56 +++ packages/hash/shared/package.json | 1 + packages/hash/shared/src/graphql/types.ts | 12 + 54 files changed, 2242 insertions(+), 907 deletions(-) create mode 100644 packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_data_type.json create mode 100644 packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_entity_type.json create mode 100644 packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_link_type.json create mode 100644 packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_property_type.json create mode 100644 packages/graph/hash_graph/lib/graph/src/ontology/domain_validator.rs diff --git a/packages/graph/README.md b/packages/graph/README.md index 99fdc00f55c..e56553a669f 100644 --- a/packages/graph/README.md +++ b/packages/graph/README.md @@ -26,6 +26,11 @@ Then, the Graph API can be started: cargo run ``` +### Logging configuration + +Some of the libraries used are very talkative in `trace` logging configurations, especially `mio`, `hyper`, and `tokio_util`. +If you're interested in just increasing the logs for the Graph, we recommend specifically targeting the crates with `RUST_LOG=graph=trace,hash_graph=trace`. + ## Development In order to build run the following command: diff --git a/packages/graph/clients/typescript/api.ts b/packages/graph/clients/typescript/api.ts index 76e7e82cf32..5fd1245a40f 100644 --- a/packages/graph/clients/typescript/api.ts +++ b/packages/graph/clients/typescript/api.ts @@ -683,6 +683,15 @@ export type PropertyValues = | PropertyArrayValue | PropertyObjectValue; +/** + * @type PropertyValues1 + * @export + */ +export type PropertyValues1 = + | DataTypeReference + | PropertyArrayValue + | PropertyObjectValue; + /** * * @export @@ -708,6 +717,47 @@ export interface RemoveLinkRequest { */ targetEntityId: string; } +/** + * The contents of a Data Type update request + * @export + * @interface UpdateDataType + */ +export interface UpdateDataType { + [key: string]: any; + + /** + * + * @type {string} + * @memberof UpdateDataType + */ + description?: string; + /** + * + * @type {object} + * @memberof UpdateDataType + */ + kind: UpdateDataTypeKindEnum; + /** + * + * @type {string} + * @memberof UpdateDataType + */ + title: string; + /** + * + * @type {string} + * @memberof UpdateDataType + */ + type: string; +} + +export const UpdateDataTypeKindEnum = { + DataType: "dataType", +} as const; + +export type UpdateDataTypeKindEnum = + typeof UpdateDataTypeKindEnum[keyof typeof UpdateDataTypeKindEnum]; + /** * * @export @@ -722,10 +772,16 @@ export interface UpdateDataTypeRequest { accountId: string; /** * - * @type {DataType} + * @type {UpdateDataType} * @memberof UpdateDataTypeRequest */ - schema: DataType; + schema: UpdateDataType; + /** + * + * @type {string} + * @memberof UpdateDataTypeRequest + */ + typeToUpdate: string; } /** * @@ -758,6 +814,93 @@ export interface UpdateEntityRequest { */ entityTypeUri: string; } +/** + * The contents of an Entity Type update request + * @export + * @interface UpdateEntityType + */ +export interface UpdateEntityType { + /** + * + * @type {object} + * @memberof UpdateEntityType + */ + default?: object; + /** + * + * @type {string} + * @memberof UpdateEntityType + */ + description?: string; + /** + * + * @type {Array} + * @memberof UpdateEntityType + */ + examples?: Array; + /** + * + * @type {object} + * @memberof UpdateEntityType + */ + kind: UpdateEntityTypeKindEnum; + /** + * + * @type {object} + * @memberof UpdateEntityType + */ + links?: object; + /** + * + * @type {string} + * @memberof UpdateEntityType + */ + pluralTitle: string; + /** + * + * @type {object} + * @memberof UpdateEntityType + */ + properties: object; + /** + * + * @type {Array} + * @memberof UpdateEntityType + */ + required?: Array; + /** + * + * @type {Array} + * @memberof UpdateEntityType + */ + requiredLinks?: Array; + /** + * + * @type {string} + * @memberof UpdateEntityType + */ + title: string; + /** + * + * @type {object} + * @memberof UpdateEntityType + */ + type: UpdateEntityTypeTypeEnum; +} + +export const UpdateEntityTypeKindEnum = { + EntityType: "entityType", +} as const; + +export type UpdateEntityTypeKindEnum = + typeof UpdateEntityTypeKindEnum[keyof typeof UpdateEntityTypeKindEnum]; +export const UpdateEntityTypeTypeEnum = { + Object: "object", +} as const; + +export type UpdateEntityTypeTypeEnum = + typeof UpdateEntityTypeTypeEnum[keyof typeof UpdateEntityTypeTypeEnum]; + /** * * @export @@ -772,11 +915,62 @@ export interface UpdateEntityTypeRequest { accountId: string; /** * - * @type {EntityType} + * @type {UpdateEntityType} * @memberof UpdateEntityTypeRequest */ - schema: EntityType; + schema: UpdateEntityType; + /** + * + * @type {string} + * @memberof UpdateEntityTypeRequest + */ + typeToUpdate: string; } +/** + * The contents of a Link Type update request + * @export + * @interface UpdateLinkType + */ +export interface UpdateLinkType { + /** + * + * @type {string} + * @memberof UpdateLinkType + */ + description: string; + /** + * + * @type {object} + * @memberof UpdateLinkType + */ + kind: UpdateLinkTypeKindEnum; + /** + * + * @type {string} + * @memberof UpdateLinkType + */ + pluralTitle: string; + /** + * + * @type {Array} + * @memberof UpdateLinkType + */ + relatedKeywords?: Array; + /** + * + * @type {string} + * @memberof UpdateLinkType + */ + title: string; +} + +export const UpdateLinkTypeKindEnum = { + LinkType: "linkType", +} as const; + +export type UpdateLinkTypeKindEnum = + typeof UpdateLinkTypeKindEnum[keyof typeof UpdateLinkTypeKindEnum]; + /** * * @export @@ -791,11 +985,62 @@ export interface UpdateLinkTypeRequest { accountId: string; /** * - * @type {LinkType} + * @type {UpdateLinkType} * @memberof UpdateLinkTypeRequest */ - schema: LinkType; + schema: UpdateLinkType; + /** + * + * @type {string} + * @memberof UpdateLinkTypeRequest + */ + typeToUpdate: string; +} +/** + * The contents of a Property Type update request + * @export + * @interface UpdatePropertyType + */ +export interface UpdatePropertyType { + /** + * + * @type {string} + * @memberof UpdatePropertyType + */ + description?: string; + /** + * + * @type {object} + * @memberof UpdatePropertyType + */ + kind: UpdatePropertyTypeKindEnum; + /** + * + * @type {Array} + * @memberof UpdatePropertyType + */ + oneOf: Array; + /** + * + * @type {string} + * @memberof UpdatePropertyType + */ + pluralTitle: string; + /** + * + * @type {string} + * @memberof UpdatePropertyType + */ + title: string; } + +export const UpdatePropertyTypeKindEnum = { + PropertyType: "propertyType", +} as const; + +export type UpdatePropertyTypeKindEnum = + typeof UpdatePropertyTypeKindEnum[keyof typeof UpdatePropertyTypeKindEnum]; + /** * * @export @@ -810,10 +1055,16 @@ export interface UpdatePropertyTypeRequest { accountId: string; /** * - * @type {PropertyType} + * @type {UpdatePropertyType} * @memberof UpdatePropertyTypeRequest */ - schema: PropertyType; + schema: UpdatePropertyType; + /** + * + * @type {string} + * @memberof UpdatePropertyTypeRequest + */ + typeToUpdate: string; } /** diff --git a/packages/graph/hash_graph/Cargo.lock b/packages/graph/hash_graph/Cargo.lock index bcb80efbfb8..20720ad4b15 100644 --- a/packages/graph/hash_graph/Cargo.lock +++ b/packages/graph/hash_graph/Cargo.lock @@ -503,6 +503,7 @@ dependencies = [ "futures", "include_dir", "postgres-types", + "regex", "serde", "serde_json", "tokio", @@ -525,6 +526,7 @@ dependencies = [ "clap_complete", "error-stack", "graph", + "regex", "serde_json", "tokio", "tokio-postgres", diff --git a/packages/graph/hash_graph/bin/hash_graph/Cargo.toml b/packages/graph/hash_graph/bin/hash_graph/Cargo.toml index 329a3f424d6..1e20338310b 100644 --- a/packages/graph/hash_graph/bin/hash_graph/Cargo.toml +++ b/packages/graph/hash_graph/bin/hash_graph/Cargo.toml @@ -14,6 +14,7 @@ clap_complete = "3.2.3" # TODO: Change to `version = "0.2"` as soon as it's released error-stack = { git = "https://github.com/hashintel/hash", rev = "5edddb5", features = ["spantrace"] } graph = { path = "../../lib/graph", features = ["clap"] } +regex = "1.6.0" serde_json = "1.0.83" tokio = { version = "1.18.2", features = ["rt-multi-thread", "macros"] } tokio-postgres = { version = "0.7.6", default-features = false } diff --git a/packages/graph/hash_graph/bin/hash_graph/src/args.rs b/packages/graph/hash_graph/bin/hash_graph/src/args.rs index 33466bc7c34..4a3deaa7b2b 100644 --- a/packages/graph/hash_graph/bin/hash_graph/src/args.rs +++ b/packages/graph/hash_graph/bin/hash_graph/src/args.rs @@ -1,6 +1,7 @@ use clap::{AppSettings::DeriveDisplayOrder, Args as _, Command, Parser}; use clap_complete::Shell; use graph::{logging::LoggingArgs, store::DatabaseConnectionInfo}; +use regex::Regex; /// Arguments passed to the program. #[derive(Debug, Parser)] @@ -12,14 +13,34 @@ pub struct Args { #[clap(flatten)] pub log_config: LoggingArgs, - /// The host the REST client is listening at + /// The host the REST client is listening at. #[clap(long, default_value = "127.0.0.1", env = "HASH_GRAPH_API_HOST")] pub api_host: String, - /// The port the REST client is listening at + /// The port the REST client is listening at. #[clap(long, default_value_t = 4000, env = "HASH_GRAPH_API_PORT")] pub api_port: u16, + /// A regex which *new* Type System URLs are checked against. Trying to create new Types with + /// a domain that doesn't satisfy the pattern will error. + /// + /// The regex must: + /// + /// - be in the standard format accepted by Rust's `regex` crate. + /// + /// - contain a capture group named "shortname" to identify a user's shortname, e.g. + /// `(?P[\w|-]+)` + /// + /// - contain a capture group named "kind" to identify the slug of the kind of ontology type + /// being hosted (data-type, property-type, entity-type, link-type), e.g. + /// `(?P(?:data-type)|(?:property-type)|(?:entity-type)|(?:link-type))` + #[clap( + long, + default_value_t = Regex::new(r"http://localhost:3000/@(?P[\w-]+)/types/(?P(?:data-type)|(?:property-type)|(?:entity-type)|(?:link-type))/[\w-]+/").unwrap(), + env = "HASH_GRAPH_ALLOWED_URL_DOMAIN_PATTERN" + )] + pub allowed_url_domain: Regex, + /// Generate a completion script for the given shell and outputs it to stdout. #[clap(long, arg_enum, exclusive = true)] generate_completion: Option, diff --git a/packages/graph/hash_graph/bin/hash_graph/src/main.rs b/packages/graph/hash_graph/bin/hash_graph/src/main.rs index 3c3aee5d6a4..714192f2e92 100644 --- a/packages/graph/hash_graph/bin/hash_graph/src/main.rs +++ b/packages/graph/hash_graph/bin/hash_graph/src/main.rs @@ -6,7 +6,7 @@ use error_stack::{Context, IntoReport, Result, ResultExt}; use graph::{ api::rest::rest_api_router, logging::init_logger, - ontology::AccountId, + ontology::{domain_validator::DomainValidator, AccountId}, store::{AccountStore, DataTypeStore, PostgresStorePool, StorePool}, }; use serde_json::json; @@ -25,7 +25,7 @@ impl Context for GraphError {} impl fmt::Display for GraphError { fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt.write_str("The Graph query layer encountered an error during execution") + fmt.write_str("the Graph query layer encountered an error during execution") } } @@ -34,6 +34,7 @@ impl fmt::Display for GraphError { /// This will include things that are mocks or stubs to make up for missing pieces of infrastructure /// that haven't been created yet. async fn stop_gap_setup(pool: &PostgresStorePool) -> Result<(), GraphError> { + // TODO: how do we make these URIs compliant let text = DataType::new( VersionedUri::new( BaseUri::new( @@ -182,7 +183,10 @@ async fn main() -> Result<(), GraphError> { stop_gap_setup(&pool).await?; - let rest_router = rest_api_router(Arc::new(pool)); + let rest_router = rest_api_router( + Arc::new(pool), + DomainValidator::new(args.allowed_url_domain), + ); let api_address = format!("{}:{}", args.api_host, args.api_port); let addr: SocketAddr = api_address .parse() diff --git a/packages/graph/hash_graph/lib/graph/Cargo.toml b/packages/graph/hash_graph/lib/graph/Cargo.toml index c945c852f2e..f13329c8df7 100644 --- a/packages/graph/hash_graph/lib/graph/Cargo.toml +++ b/packages/graph/hash_graph/lib/graph/Cargo.toml @@ -16,6 +16,7 @@ chrono = { version = "0.4.19", features = ["serde"] } error-stack = { git = "https://github.com/hashintel/hash", rev = "5edddb5", features = ["spantrace", "futures"] } futures = "0.3.21" postgres-types = { version = "0.2.3", default-features = false, features = ["derive", "with-uuid-1", "with-serde_json-1", "with-chrono-0_4"] } +regex = "1.6.0" serde = { version = "1.0.137", features = ["derive"] } serde_json = "1.0.83" tokio-postgres = { version = "0.7.6", default-features = false } diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/data_type.rs b/packages/graph/hash_graph/lib/graph/src/api/rest/data_type.rs index d8535636e66..2ef70e15adf 100644 --- a/packages/graph/hash_graph/lib/graph/src/api/rest/data_type.rs +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/data_type.rs @@ -16,7 +16,10 @@ use utoipa::{Component, OpenApi}; use super::api_resource::RoutedResource; use crate::{ api::rest::read_from_store, - ontology::{AccountId, PersistedDataType, PersistedOntologyIdentifier}, + ontology::{ + domain_validator::{DomainValidator, ValidateOntologyType}, + patch_id_and_parse, AccountId, PersistedDataType, PersistedOntologyIdentifier, + }, store::{ query::Expression, BaseUriAlreadyExists, BaseUriDoesNotExist, DataTypeStore, StorePool, }, @@ -88,21 +91,27 @@ struct CreateDataTypeRequest { async fn create_data_type( body: Json, pool: Extension>, + domain_validator: Extension, ) -> Result, StatusCode> { let Json(CreateDataTypeRequest { schema, account_id }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let data_type: DataType = schema.try_into().into_report().map_err(|report| { - tracing::error!(error=?report, "Couldn't convert schema to Property Type"); + tracing::error!(error=?report, "Couldn't convert schema to Data Type"); StatusCode::UNPROCESSABLE_ENTITY // TODO - We should probably return more information to the client // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + domain_validator.validate(&data_type).map_err(|report| { + tracing::error!(error=?report, id=data_type.id().to_string(), "Data Type ID failed to validate"); + StatusCode::UNPROCESSABLE_ENTITY + })?; + + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .create_data_type(data_type, account_id) .await @@ -185,8 +194,10 @@ async fn get_data_type( #[derive(Component, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateDataTypeRequest { - #[component(value_type = VAR_DATA_TYPE)] + #[component(value_type = VAR_UPDATE_DATA_TYPE)] schema: serde_json::Value, + #[component(value_type = String)] + type_to_update: VersionedUri, account_id: AccountId, } @@ -207,20 +218,29 @@ async fn update_data_type( body: Json, pool: Extension>, ) -> Result, StatusCode> { - let Json(UpdateDataTypeRequest { schema, account_id }) = body; + let Json(UpdateDataTypeRequest { + schema, + type_to_update, + account_id, + }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let new_type_id = VersionedUri::new( + type_to_update.base_uri().clone(), + type_to_update.version() + 1, + ); - let data_type: DataType = schema.try_into().into_report().map_err(|report| { - tracing::error!(error=?report, "Couldn't convert schema to Property Type"); + let data_type = patch_id_and_parse(&new_type_id, schema).map_err(|report| { + tracing::error!(error=?report, "Couldn't patch schema and convert to Data Type"); StatusCode::UNPROCESSABLE_ENTITY // TODO - We should probably return more information to the client // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .update_data_type(data_type, account_id) .await diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/entity_type.rs b/packages/graph/hash_graph/lib/graph/src/api/rest/entity_type.rs index 52029af1644..61f0d76a7af 100644 --- a/packages/graph/hash_graph/lib/graph/src/api/rest/entity_type.rs +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/entity_type.rs @@ -15,7 +15,10 @@ use utoipa::{Component, OpenApi}; use crate::{ api::rest::{api_resource::RoutedResource, read_from_store}, - ontology::{AccountId, PersistedEntityType, PersistedOntologyIdentifier}, + ontology::{ + domain_validator::{DomainValidator, ValidateOntologyType}, + patch_id_and_parse, AccountId, PersistedEntityType, PersistedOntologyIdentifier, + }, store::{ error::{BaseUriAlreadyExists, BaseUriDoesNotExist}, query::Expression, @@ -89,14 +92,10 @@ struct CreateEntityTypeRequest { async fn create_entity_type( body: Json, pool: Extension>, + domain_validator: Extension, ) -> Result, StatusCode> { let Json(CreateEntityTypeRequest { schema, account_id }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let entity_type: EntityType = schema.try_into().into_report().map_err(|report| { tracing::error!(error=?report, "Couldn't convert schema to Entity Type"); // Shame there isn't an UNPROCESSABLE_ENTITY_TYPE code :D @@ -105,6 +104,16 @@ async fn create_entity_type( // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + domain_validator.validate(&entity_type).map_err(|report| { + tracing::error!(error=?report, id=entity_type.id().to_string(), "Entity Type ID failed to validate"); + StatusCode::UNPROCESSABLE_ENTITY + })?; + + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .create_entity_type(entity_type, account_id) .await @@ -186,8 +195,10 @@ async fn get_entity_type( #[derive(Component, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateEntityTypeRequest { - #[component(value_type = VAR_ENTITY_TYPE)] + #[component(value_type = VAR_UPDATE_ENTITY_TYPE)] schema: serde_json::Value, + #[component(value_type = String)] + type_to_update: VersionedUri, account_id: AccountId, } @@ -208,14 +219,18 @@ async fn update_entity_type( body: Json, pool: Extension>, ) -> Result, StatusCode> { - let Json(UpdateEntityTypeRequest { schema, account_id }) = body; + let Json(UpdateEntityTypeRequest { + schema, + type_to_update, + account_id, + }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let new_type_id = VersionedUri::new( + type_to_update.base_uri().clone(), + type_to_update.version() + 1, + ); - let entity_type: EntityType = schema.try_into().into_report().map_err(|report| { + let entity_type = patch_id_and_parse(&new_type_id, schema).map_err(|report| { tracing::error!(error=?report, "Couldn't convert schema to Entity Type"); // Shame there isn't an UNPROCESSABLE_ENTITY_TYPE code :D StatusCode::UNPROCESSABLE_ENTITY @@ -223,6 +238,11 @@ async fn update_entity_type( // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .update_entity_type(entity_type, account_id) .await diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_data_type.json b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_data_type.json new file mode 100644 index 00000000000..8cf2ee8cbbd --- /dev/null +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_data_type.json @@ -0,0 +1,14 @@ +{ + "description": "The contents of a Data Type update request", + "type": "object", + "properties": { + "kind": { + "enum": ["dataType"] + }, + "title": { "type": "string" }, + "description": { "type": "string" }, + "type": { "type": "string" } + }, + "required": ["kind", "title", "type"], + "additionalProperties": true +} diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_entity_type.json b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_entity_type.json new file mode 100644 index 00000000000..63fd6bd4d95 --- /dev/null +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_entity_type.json @@ -0,0 +1,157 @@ +{ + "description": "The contents of an Entity Type update request", + "type": "object", + "properties": { + "kind": { + "enum": ["entityType"] + }, + "type": { + "enum": ["object"] + }, + "title": { "type": "string" }, + "pluralTitle": { "type": "string" }, + "description": { "type": "string" }, + "default": { + "$comment": "Default Entity instance", + "type": "object", + "propertyNames": { + "$comment": "Property names must be a valid URI to a Property Type", + "type": "string", + "format": "uri" + } + }, + "examples": { + "$comment": "Example Entity instances", + "type": "array", + "items": { + "type": "object", + "propertyNames": { + "$comment": "Property names must be a valid URI to a Property Type", + "type": "string", + "format": "uri" + } + } + }, + "properties": { "$ref": "#/$defs/propertyTypeObject" }, + "required": { + "type": "array", + "items": { + "type": "string", + "format": "uri" + } + }, + "links": { "$ref": "#/$defs/linkTypeObject" }, + "requiredLinks": { + "$comment": "A list of link-types which are required. This is a separate field to 'required' to avoid breaking standard JSON schema validation", + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "required": ["kind", "type", "title", "pluralTitle", "properties"], + "$defs": { + "propertyTypeObject": { + "type": "object", + "propertyNames": { + "$comment": "Property names must be a valid URI to a Property Type", + "type": "string", + "format": "uri" + }, + "patternProperties": { + ".*": { + "oneOf": [ + { + "$ref": "#/$defs/propertyTypeReference" + }, + { + "type": "object", + "properties": { + "type": { + "enum": ["array"] + }, + "items": { + "$ref": "#/$defs/propertyTypeReference" + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "maxItems": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "items"], + "additionalProperties": false + } + ] + } + }, + "minimumProperties": 1 + }, + "propertyTypeReference": { + "type": "object", + "properties": { + "$ref": { + "$comment": "Property Object values must be defined through references to the same valid URI to a Property Type", + "type": "string", + "format": "uri" + } + }, + "required": ["$ref"], + "additionalProperties": false + }, + "linkTypeObject": { + "type": "object", + "propertyNames": { + "$comment": "Property names must be a valid URI to a Property Type", + "type": "string", + "format": "uri" + }, + "patternProperties": { + ".*": { + "oneOf": [ + { + "$ref": "#/$defs/entityTypeReference" + }, + { + "type": "object", + "properties": { + "type": { + "enum": ["array"] + }, + "items": { + "$ref": "#/$defs/entityTypeReference" + }, + "ordered": { "type": "boolean", "default": false }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "maxItems": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "ordered"], + "additionalProperties": false + } + ] + } + } + }, + "entityTypeReference": { + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri" + } + }, + "required": ["$ref"], + "additionalProperties": false + } + } +} diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_link_type.json b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_link_type.json new file mode 100644 index 00000000000..f8e69bfb9f8 --- /dev/null +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_link_type.json @@ -0,0 +1,17 @@ +{ + "description": "The contents of a Link Type update request", + "properties": { + "kind": { + "enum": ["linkType"] + }, + "title": { "type": "string" }, + "pluralTitle": { "type": "string" }, + "description": { "type": "string" }, + "relatedKeywords": { + "type": "array", + "items": { "type": "string" } + } + }, + "additionalProperties": false, + "required": ["kind", "title", "pluralTitle", "description"] +} diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_property_type.json b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_property_type.json new file mode 100644 index 00000000000..25b0355a9a1 --- /dev/null +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/json_schemas/update_property_type.json @@ -0,0 +1,150 @@ +{ + "description": "The contents of a Property Type update request", + "type": "object", + "properties": { + "kind": { + "enum": ["propertyType"] + }, + "title": { + "type": "string" + }, + "pluralTitle": { + "type": "string" + }, + "description": { + "type": "string" + }, + "oneOf": { + "type": "array", + "items": { + "$ref": "#/$defs/propertyValues" + } + } + }, + "required": ["kind", "title", "pluralTitle", "oneOf"], + "additionalProperties": false, + "$defs": { + "propertyValues": { + "$id": "propertyValues", + "title": "propertyValues", + "$comment": "The definition of potential property values, made up of a `oneOf` keyword which has a list of options of either references to Data Types, or objects made up of more Property Types", + "oneOf": [ + { + "$ref": "#/$defs/dataTypeReference" + }, + { + "title": "propertyObjectValue", + "type": "object", + "properties": { + "type": { + "enum": ["object"] + }, + "properties": { + "$ref": "propertyTypeObject" + } + }, + "required": ["type", "properties"], + "additionalProperties": false + }, + { + "title": "propertyArrayValue", + "type": "object", + "properties": { + "type": { + "enum": ["array"] + }, + "items": { + "type": "object", + "properties": { + "oneOf": { + "type": "array", + "items": { + "$ref": "propertyValues" + }, + "minItems": 1 + } + }, + "required": ["oneOf"], + "additionalProperties": false + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "maxItems": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "items"], + "additionalProperties": false + } + ] + }, + "propertyTypeObject": { + "$id": "propertyTypeObject", + "title": "propertyTypeObject", + "type": "object", + "propertyNames": { + "$comment": "Property names must be a valid URI to a Property Type", + "type": "string", + "format": "uri" + }, + "patternProperties": { + ".*": { + "oneOf": [ + { + "$ref": "#/$defs/propertyTypeReference" + }, + { + "type": "object", + "properties": { + "type": { + "enum": ["array"] + }, + "items": { + "$ref": "#/$defs/propertyTypeReference" + }, + "minItems": { + "type": "integer", + "minimum": 0 + }, + "maxItems": { + "type": "integer", + "minimum": 0 + } + }, + "required": ["type", "items"], + "additionalProperties": false + } + ] + } + }, + "minimumProperties": 1 + }, + "propertyTypeReference": { + "type": "object", + "properties": { + "$ref": { + "$comment": "Property Object values must be defined through references to the same valid URI to a Property Type", + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false, + "required": ["$ref"] + }, + "dataTypeReference": { + "title": "dataTypeReference", + "type": "object", + "properties": { + "$ref": { + "type": "string", + "format": "uri" + } + }, + "additionalProperties": false, + "required": ["$ref"] + } + } +} diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/link_type.rs b/packages/graph/hash_graph/lib/graph/src/api/rest/link_type.rs index 0e53c158a39..74f71114ff0 100644 --- a/packages/graph/hash_graph/lib/graph/src/api/rest/link_type.rs +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/link_type.rs @@ -16,7 +16,10 @@ use utoipa::{Component, OpenApi}; use super::api_resource::RoutedResource; use crate::{ api::rest::read_from_store, - ontology::{AccountId, PersistedLinkType, PersistedOntologyIdentifier}, + ontology::{ + domain_validator::{DomainValidator, ValidateOntologyType}, + patch_id_and_parse, AccountId, PersistedLinkType, PersistedOntologyIdentifier, + }, store::{ query::Expression, BaseUriAlreadyExists, BaseUriDoesNotExist, LinkTypeStore, StorePool, }, @@ -88,14 +91,10 @@ struct CreateLinkTypeRequest { async fn create_link_type( body: Json, pool: Extension>, + domain_validator: Extension, ) -> Result, StatusCode> { let Json(CreateLinkTypeRequest { schema, account_id }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let link_type: LinkType = schema.try_into().into_report().map_err(|report| { tracing::error!(error=?report, "Couldn't convert schema to Link Type"); StatusCode::UNPROCESSABLE_ENTITY @@ -103,6 +102,16 @@ async fn create_link_type( // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + domain_validator.validate(&link_type).map_err(|report| { + tracing::error!(error=?report, id=link_type.id().to_string(), "Link Type ID failed to validate"); + StatusCode::UNPROCESSABLE_ENTITY + })?; + + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .create_link_type(link_type, account_id) .await @@ -184,8 +193,10 @@ async fn get_link_type( #[derive(Component, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct UpdateLinkTypeRequest { - #[component(value_type = VAR_LINK_TYPE)] + #[component(value_type = VAR_UPDATE_LINK_TYPE)] schema: serde_json::Value, + #[component(value_type = String)] + type_to_update: VersionedUri, account_id: AccountId, } @@ -206,20 +217,29 @@ async fn update_link_type( body: Json, pool: Extension>, ) -> Result, StatusCode> { - let Json(UpdateLinkTypeRequest { schema, account_id }) = body; + let Json(UpdateLinkTypeRequest { + schema, + type_to_update, + account_id, + }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let new_type_id = VersionedUri::new( + type_to_update.base_uri().clone(), + type_to_update.version() + 1, + ); - let link_type: LinkType = schema.try_into().into_report().map_err(|report| { + let link_type = patch_id_and_parse(&new_type_id, schema).map_err(|report| { tracing::error!(error=?report, "Couldn't convert schema to Link Type"); StatusCode::UNPROCESSABLE_ENTITY // TODO - We should probably return more information to the client // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .update_link_type(link_type, account_id) .await diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/mod.rs b/packages/graph/hash_graph/lib/graph/src/api/rest/mod.rs index 55287343469..fcec843f7cb 100644 --- a/packages/graph/hash_graph/lib/graph/src/api/rest/mod.rs +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/mod.rs @@ -27,10 +27,13 @@ use utoipa::{ }; use self::api_resource::RoutedResource; -use crate::store::{ - crud::Read, - query::{Expression, ExpressionError, ResolveError}, - StorePool, +use crate::{ + ontology::domain_validator::DomainValidator, + store::{ + crud::Read, + query::{Expression, ExpressionError, ResolveError}, + StorePool, + }, }; static STATIC_SCHEMAS: Dir<'_> = include_dir!("$CARGO_MANIFEST_DIR/src/api/rest/json_schemas"); @@ -91,7 +94,10 @@ where }) } -pub fn rest_api_router(store: Arc

) -> Router { +pub fn rest_api_router( + store: Arc

, + domain_regex: DomainValidator, +) -> Router { // All api resources are merged together into a super-router. let merged_routes = api_resources::

() .into_iter() @@ -102,18 +108,21 @@ pub fn rest_api_router(store: Arc

) -> Router { // super-router can then be used as any other router. // Make sure extensions are added at the end so they are made available to merged routers. - merged_routes.layer(Extension(store)).nest( - "/api-doc", - Router::new() - .route( - "/openapi.json", - get({ - let doc = open_api_doc; - move || async { Json(doc) } - }), - ) - .route("/models/*path", get(serve_static_schema)), - ) + merged_routes + .layer(Extension(store)) + .layer(Extension(domain_regex)) + .nest( + "/api-doc", + Router::new() + .route( + "/openapi.json", + get({ + let doc = open_api_doc; + move || async { Json(doc) } + }), + ) + .route("/models/*path", get(serve_static_schema)), + ) } #[allow( diff --git a/packages/graph/hash_graph/lib/graph/src/api/rest/property_type.rs b/packages/graph/hash_graph/lib/graph/src/api/rest/property_type.rs index a7dcf949961..fa60b58b237 100644 --- a/packages/graph/hash_graph/lib/graph/src/api/rest/property_type.rs +++ b/packages/graph/hash_graph/lib/graph/src/api/rest/property_type.rs @@ -16,7 +16,10 @@ use utoipa::{Component, OpenApi}; use super::api_resource::RoutedResource; use crate::{ api::rest::read_from_store, - ontology::{AccountId, PersistedOntologyIdentifier, PersistedPropertyType}, + ontology::{ + domain_validator::{DomainValidator, ValidateOntologyType}, + patch_id_and_parse, AccountId, PersistedOntologyIdentifier, PersistedPropertyType, + }, store::{ query::Expression, BaseUriAlreadyExists, BaseUriDoesNotExist, PropertyTypeStore, StorePool, }, @@ -88,14 +91,10 @@ struct CreatePropertyTypeRequest { async fn create_property_type( body: Json, pool: Extension>, + domain_validator: Extension, ) -> Result, StatusCode> { let Json(CreatePropertyTypeRequest { schema, account_id }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; - let property_type: PropertyType = schema.try_into().into_report().map_err(|report| { tracing::error!(error=?report, "Couldn't convert schema to Property Type"); StatusCode::UNPROCESSABLE_ENTITY @@ -103,6 +102,18 @@ async fn create_property_type( // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + domain_validator + .validate(&property_type) + .map_err(|report| { + tracing::error!(error=?report, id=property_type.id().to_string(), "Property Type ID failed to validate"); + StatusCode::UNPROCESSABLE_ENTITY + })?; + + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .create_property_type(property_type, account_id) .await @@ -184,8 +195,10 @@ async fn get_property_type( #[derive(Component, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] struct UpdatePropertyTypeRequest { - #[component(value_type = VAR_PROPERTY_TYPE)] + #[component(value_type = VAR_UPDATE_PROPERTY_TYPE)] schema: serde_json::Value, + #[component(value_type = String)] + type_to_update: VersionedUri, account_id: AccountId, } @@ -206,20 +219,29 @@ async fn update_property_type( body: Json, pool: Extension>, ) -> Result, StatusCode> { - let Json(UpdatePropertyTypeRequest { schema, account_id }) = body; + let Json(UpdatePropertyTypeRequest { + schema, + type_to_update, + account_id, + }) = body; - let mut store = pool.acquire().await.map_err(|report| { - tracing::error!(error=?report, "Could not acquire store"); - StatusCode::INTERNAL_SERVER_ERROR - })?; + let new_type_id = VersionedUri::new( + type_to_update.base_uri().clone(), + type_to_update.version() + 1, + ); - let property_type: PropertyType = schema.try_into().into_report().map_err(|report| { - tracing::error!(error=?report, "Couldn't convert schema to Property Type"); + let property_type = patch_id_and_parse(&new_type_id, schema).map_err(|report| { + tracing::error!(error=?report, "Couldn't patch schema and convert to Property Type"); StatusCode::UNPROCESSABLE_ENTITY // TODO - We should probably return more information to the client // https://app.asana.com/0/1201095311341924/1202574350052904/f })?; + let mut store = pool.acquire().await.map_err(|report| { + tracing::error!(error=?report, "Could not acquire store"); + StatusCode::INTERNAL_SERVER_ERROR + })?; + store .update_property_type(property_type, account_id) .await diff --git a/packages/graph/hash_graph/lib/graph/src/ontology/domain_validator.rs b/packages/graph/hash_graph/lib/graph/src/ontology/domain_validator.rs new file mode 100644 index 00000000000..e8ce10acc4a --- /dev/null +++ b/packages/graph/hash_graph/lib/graph/src/ontology/domain_validator.rs @@ -0,0 +1,226 @@ +use std::fmt; + +use error_stack::{Context, IntoReport, ResultExt}; +use regex::{Captures, Regex}; +use type_system::{DataType, EntityType, LinkType, PropertyType}; + +#[derive(Debug)] +pub struct DomainValidationError; + +impl Context for DomainValidationError {} + +impl fmt::Display for DomainValidationError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("URL failed to validate") + } +} + +pub trait ValidateOntologyType { + /// Checks a given type's ID against the given domain validation regex. + /// + /// # Errors + /// + /// - [`DomainValidationError`], if the base URI doesn't match or the kind is invalid + fn validate(&self, ontology_type: &T) -> error_stack::Result<(), DomainValidationError>; +} + +#[expect(dead_code, reason = "We currently don't validate the shortname")] +struct ShortNameAndKind<'a> { + pub short_name: &'a str, + pub kind: &'a str, +} + +/// Responsible for validating Type URIs against a known valid pattern. +#[derive(Clone)] +pub struct DomainValidator(Regex); + +impl DomainValidator { + #[must_use] + /// Creates a new `DomainValidator` + /// + /// # Panics + /// + /// - If the "shortname" or "kind" named capture groups are missing + pub fn new(regex: Regex) -> Self { + regex + .capture_names() + .position(|capture_name| capture_name == Some("shortname")) + .expect("shortname capture group was missing"); + + regex + .capture_names() + .position(|capture_name| capture_name == Some("kind")) + .expect("kind capture group was missing"); + + Self(regex) + } + + #[must_use] + pub fn validate_url(&self, url: &str) -> bool { + self.0.is_match(url) + } + + fn captures<'a>( + &'a self, + url: &'a str, + ) -> error_stack::Result { + self.0 + .captures(url) + .ok_or(DomainValidationError) + .into_report() + } + + /// Returns the captures of the groups "shortname" and "kind" + /// + /// # Errors + /// + /// - [`DomainValidationError`], if "shortname" or "kind" didn't capture anything + fn extract_shortname_and_kind<'a>( + &'a self, + url: &'a str, + ) -> error_stack::Result { + let captures = self.captures(url)?; + + let short_name = captures + .name("shortname") + .map(|matched| matched.as_str()) + .ok_or(DomainValidationError) + .into_report() + .attach_printable("missing shortname")?; + + let kind = captures + .name("kind") + .map(|matched| matched.as_str()) + .ok_or(DomainValidationError) + .into_report() + .attach_printable("missing ontology type kind")?; + + Ok(ShortNameAndKind { short_name, kind }) + } +} + +impl ValidateOntologyType for DomainValidator { + fn validate(&self, ontology_type: &DataType) -> error_stack::Result<(), DomainValidationError> { + let base_uri = ontology_type.id().base_uri(); + + if !self.validate_url(base_uri.as_str()) { + return Err(DomainValidationError) + .into_report() + .attach_printable("Data Type base URI didn't match the given validation regex"); + }; + + let ShortNameAndKind { + short_name: _, + kind, + } = self.extract_shortname_and_kind(base_uri.as_str())?; + if kind != "data-type" { + return Err(DomainValidationError) + .into_report() + .attach_printable_lazy(|| { + format!("Data Type base URI had the incorrect ontology kind slug: {kind}") + }); + }; + + // TODO - check that the user has write access to the shortname, this will require us + // making the graph aware of shortnames. We can store them alongside accountIds. We should + // not have to make the graph aware of User entities being a thing however. + // https://app.asana.com/0/1202805690238892/1202931031833224/f + Ok(()) + } +} + +impl ValidateOntologyType for DomainValidator { + fn validate( + &self, + ontology_type: &PropertyType, + ) -> error_stack::Result<(), DomainValidationError> { + let base_uri = ontology_type.id().base_uri(); + + if !self.validate_url(base_uri.as_str()) { + return Err(DomainValidationError).into_report().attach_printable( + "Property Type base URI didn't match the given validation regex", + ); + }; + + let ShortNameAndKind { + short_name: _, + kind, + } = self.extract_shortname_and_kind(base_uri.as_str())?; + if kind != "property-type" { + return Err(DomainValidationError) + .into_report() + .attach_printable_lazy(|| { + format!("Property Type base URI had the incorrect ontology kind slug: {kind}") + }); + }; + + // TODO - check that the user has write access to the shortname, this will require us + // making the graph aware of shortnames. We can store them alongside accountIds. We should + // not have to make the graph aware of User entities being a thing however. + // https://app.asana.com/0/1202805690238892/1202931031833224/f + Ok(()) + } +} + +impl ValidateOntologyType for DomainValidator { + fn validate( + &self, + ontology_type: &EntityType, + ) -> error_stack::Result<(), DomainValidationError> { + let base_uri = ontology_type.id().base_uri(); + + if !self.validate_url(base_uri.as_str()) { + return Err(DomainValidationError) + .into_report() + .attach_printable("Entity Type base URI didn't match the given validation regex"); + }; + + let ShortNameAndKind { + short_name: _, + kind, + } = self.extract_shortname_and_kind(base_uri.as_str())?; + if kind != "entity-type" { + return Err(DomainValidationError) + .into_report() + .attach_printable_lazy(|| { + format!("Entity Type base URI had the incorrect ontology kind slug: {kind}") + }); + }; + + // TODO - check that the user has write access to the shortname, this will require us + // making the graph aware of shortnames. We can store them alongside accountIds. We should + // not have to make the graph aware of User entities being a thing however. + // https://app.asana.com/0/1202805690238892/1202931031833224/f + Ok(()) + } +} + +impl ValidateOntologyType for DomainValidator { + fn validate(&self, ontology_type: &LinkType) -> error_stack::Result<(), DomainValidationError> { + let base_uri = ontology_type.id().base_uri(); + + if !self.validate_url(base_uri.as_str()) { + return Err(DomainValidationError) + .into_report() + .attach_printable("Link Type base URI didn't match the given validation regex"); + }; + + let ShortNameAndKind { + short_name: _, + kind, + } = self.extract_shortname_and_kind(base_uri.as_str())?; + if kind != "link-type" { + return Err(DomainValidationError) + .into_report() + .attach_printable_lazy(|| { + format!("Link Type base URI had the incorrect ontology kind slug: {kind}") + }); + }; + + // TODO - check that the user has write access to the shortname, this will require us + // making the graph aware of shortnames. We can store them alongside accountIds. We should + // not have to make the graph aware of User entities being a thing however. + // https://app.asana.com/0/1202805690238892/1202931031833224/f + Ok(()) + } +} diff --git a/packages/graph/hash_graph/lib/graph/src/ontology/mod.rs b/packages/graph/hash_graph/lib/graph/src/ontology/mod.rs index 9614e23588a..b65568b53d6 100644 --- a/packages/graph/hash_graph/lib/graph/src/ontology/mod.rs +++ b/packages/graph/hash_graph/lib/graph/src/ontology/mod.rs @@ -1,7 +1,10 @@ //! TODO: DOC +pub mod domain_validator; + use core::fmt; +use error_stack::{Context, IntoReport, Result, ResultExt}; use serde::{Deserialize, Serialize, Serializer}; use serde_json; use tokio_postgres::types::{FromSql, ToSql}; @@ -56,7 +59,62 @@ impl PersistedOntologyIdentifier { } } -fn serialize_ontology_type(ontology_type: &T, serializer: S) -> Result +#[derive(Debug)] +pub struct PatchAndParseError; +impl Context for PatchAndParseError {} + +impl fmt::Display for PatchAndParseError { + fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt.write_str("failed to patch schema's id and parse as type") + } +} + +/// Takes the [`serde_json::Value`] representation of an ontology type schema (without an "$id" +/// field), inserts the given [`VersionedUri`] under the "$id" key, and tries to deserialize the +/// type. +/// +/// # Errors +/// +/// - [`PatchAndParseError`] if +/// - "$id" already existed +/// - the [`serde_json::Value`] wasn't an 'Object' +/// - deserializing into `T` failed +pub fn patch_id_and_parse( + id: &VersionedUri, + mut value: serde_json::Value, +) -> Result +where + T: TryFrom, +{ + if let Some(object) = value.as_object_mut() { + if let Some(previous_val) = object.insert( + "$id".to_owned(), + serde_json::to_value(id).expect("failed to deserialize id"), + ) { + return Err(PatchAndParseError) + .into_report() + .attach_printable("schema already had an $id") + .attach_printable(previous_val); + } + } else { + return Err(PatchAndParseError) + .into_report() + .attach_printable("unexpected schema format, couldn't parse as object") + .attach_printable(value); + } + + let ontology_type: T = value + .try_into() + .into_report() + .change_context(PatchAndParseError)?; + + Ok(ontology_type) +} + +fn serialize_ontology_type( + ontology_type: &T, + serializer: S, +) -> std::result::Result where T: Clone, serde_json::Value: From, diff --git a/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/entity_type/mod.rs b/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/entity_type/mod.rs index 7b4fd551488..4c7b6cf57bb 100644 --- a/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/entity_type/mod.rs +++ b/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/entity_type/mod.rs @@ -34,7 +34,12 @@ impl EntityTypeStore for PostgresStore { .insert_entity_type_references(&entity_type, version_id) .await .change_context(InsertionError) - .attach_printable("Could not insert references for entity type") + .attach_printable_lazy(|| { + format!( + "could not insert references for entity type: {}", + entity_type.id() + ) + }) .attach_lazy(|| entity_type.clone())?; transaction @@ -69,7 +74,12 @@ impl EntityTypeStore for PostgresStore { .insert_entity_type_references(&entity_type, version_id) .await .change_context(UpdateError) - .attach_printable("Could not insert references for entity type") + .attach_printable_lazy(|| { + format!( + "could not insert references for entity type: {}", + entity_type.id() + ) + }) .attach_lazy(|| entity_type.clone())?; transaction diff --git a/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/property_type/mod.rs b/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/property_type/mod.rs index 5f6347a894b..6e292f4e0bc 100644 --- a/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/property_type/mod.rs +++ b/packages/graph/hash_graph/lib/graph/src/store/postgres/ontology/property_type/mod.rs @@ -36,7 +36,12 @@ impl PropertyTypeStore for PostgresStore { .insert_property_type_references(&property_type, version_id) .await .change_context(InsertionError) - .attach_printable("Could not insert references for property type") + .attach_printable_lazy(|| { + format!( + "could not insert references for property type: {}", + property_type.id() + ) + }) .attach_lazy(|| property_type.clone())?; transaction @@ -73,7 +78,12 @@ impl PropertyTypeStore for PostgresStore { .insert_property_type_references(&property_type, version_id) .await .change_context(UpdateError) - .attach_printable("Could not insert references for property type") + .attach_printable_lazy(|| { + format!( + "could not insert references for property type: {}", + property_type.id() + ) + }) .attach_lazy(|| property_type.clone())?; transaction diff --git a/packages/graph/hash_graph/tests/rest-test.http b/packages/graph/hash_graph/tests/rest-test.http index f0f13d5b3fe..af86b25bb4e 100644 --- a/packages/graph/hash_graph/tests/rest-test.http +++ b/packages/graph/hash_graph/tests/rest-test.http @@ -29,7 +29,7 @@ Accept: application/json "accountId": "{{account_id}}", "schema": { "kind": "dataType", - "$id": "https://example.com/data-type/text/v/1", + "$id": "http://localhost:3000/@alice/types/data-type/text/v/1", "title": "Text", "type": "string" } @@ -58,9 +58,9 @@ Accept: application/json { "accountId": "{{account_id}}", + "typeToUpdate": "http://localhost:3000/@alice/types/data-type/text/v/1", "schema": { "kind": "dataType", - "$id": "https://example.com/data-type/text/v/2", "title": "Text", "description": "An ordered sequence of characters", "type": "string" @@ -82,12 +82,12 @@ Accept: application/json "accountId": "{{account_id}}", "schema": { "kind": "propertyType", - "$id": "https://blockprotocol.org/@alice/types/property-type/name/v/1", + "$id": "http://localhost:3000/@alice/types/property-type/name/v/1", "title": "Name", "pluralTitle": "Names", "oneOf": [ { - "$ref": "https://example.com/data-type/text/v/1" + "$ref": "http://localhost:3000/@alice/types/data-type/text/v/1" } ] } @@ -116,14 +116,14 @@ Accept: application/json { "accountId": "{{account_id}}", + "typeToUpdate": "http://localhost:3000/@alice/types/property-type/name/v/1", "schema": { "kind": "propertyType", - "$id": "https://blockprotocol.org/@alice/types/property-type/name/v/2", "title": "Name", "pluralTitle": "Names", "oneOf": [ { - "$ref": "https://example.com/data-type/text/v/2" + "$ref": "http://localhost:3000/@alice/types/data-type/text/v/2" } ] } @@ -154,7 +154,7 @@ Accept: application/json "accountId": "{{account_id}}", "schema": { "kind": "linkType", - "$id": "https://blockprotocol.org/@alice/types/link-type/friend-of/v/1", + "$id": "http://localhost:3000/@alice/types/link-type/friend-of/v/1", "title": "Friend Of", "pluralTitle": "Friends Of", "description": "Someone who has a shared bond of mutual affection", @@ -186,9 +186,9 @@ Accept: application/json { "accountId": "{{account_id}}", + "typeToUpdate": "http://localhost:3000/@alice/types/link-type/friend-of/v/1", "schema": { "kind": "linkType", - "$id": "https://blockprotocol.org/@alice/types/link-type/friend-of/v/2", "title": "Friend Of", "pluralTitle": "Friends Of", "description": "Someone who has a shared bond of mutual affection", @@ -223,13 +223,13 @@ Accept: application/json "accountId": "{{account_id}}", "schema": { "kind": "entityType", - "$id": "https://blockprotocol.org/@alice/types/entity-type/person/v/1", + "$id": "http://localhost:3000/@alice/types/entity-type/person/v/1", "type": "object", "title": "Person", "pluralTitle": "People", "properties": { - "https://blockprotocol.org/@alice/types/property-type/name/": { - "$ref": "https://blockprotocol.org/@alice/types/property-type/name/v/1" + "http://localhost:3000/@alice/types/property-type/name/": { + "$ref": "http://localhost:3000/@alice/types/property-type/name/v/1" } } } @@ -258,22 +258,22 @@ Accept: application/json { "accountId": "{{account_id}}", + "typeToUpdate": "http://localhost:3000/@alice/types/entity-type/person/v/1", "schema": { "kind": "entityType", - "$id": "https://blockprotocol.org/@alice/types/entity-type/person/v/2", "type": "object", "title": "Person", "pluralTitle": "People", "properties": { - "https://blockprotocol.org/@alice/types/property-type/name/": { - "$ref": "https://blockprotocol.org/@alice/types/property-type/name/v/2" + "http://localhost:3000/@alice/types/property-type/name/": { + "$ref": "http://localhost:3000/@alice/types/property-type/name/v/2" } }, "links": { - "https://blockprotocol.org/@alice/types/link-type/friend-of/v/2": { + "http://localhost:3000/@alice/types/link-type/friend-of/v/2": { "type": "array", "items": { - "$ref": "https://blockprotocol.org/@alice/types/entity-type/person/v/2" + "$ref": "http://localhost:3000/@alice/types/entity-type/person/v/2" }, "ordered": false } @@ -306,9 +306,9 @@ Accept: application/json { "accountId": "{{account_id}}", "entity": { - "https://blockprotocol.org/@alice/types/property-type/name/": "Alice" + "http://localhost:3000/@alice/types/property-type/name/": "Alice" }, - "entityTypeUri": "https://blockprotocol.org/@alice/types/entity-type/person/v/1" + "entityTypeUri": "http://localhost:3000/@alice/types/entity-type/person/v/1" } > {% @@ -335,9 +335,9 @@ Accept: application/json { "accountId": "{{account_id}}", "entityId": "{{person_a_entity_id}}", - "entityTypeUri": "https://blockprotocol.org/@alice/types/entity-type/person/v/2", + "entityTypeUri": "http://localhost:3000/@alice/types/entity-type/person/v/2", "entity": { - "https://blockprotocol.org/@alice/types/property-type/name/": "Alice Allison" + "http://localhost:3000/@alice/types/property-type/name/": "Alice Allison" } } @@ -355,9 +355,9 @@ Accept: application/json { "accountId": "{{account_id}}", "entity": { - "https://blockprotocol.org/@alice/types/property-type/name/": "Bob" + "http://localhost:3000/@alice/types/property-type/name/": "Bob" }, - "entityTypeUri": "https://blockprotocol.org/@alice/types/entity-type/person/v/1" + "entityTypeUri": "http://localhost:3000/@alice/types/entity-type/person/v/1" } > {% diff --git a/packages/hash/api/codegen.yml b/packages/hash/api/codegen.yml index f7ce10c0fc3..cc9f1d81983 100644 --- a/packages/hash/api/codegen.yml +++ b/packages/hash/api/codegen.yml @@ -27,6 +27,10 @@ generates: PropertyType: "@blockprotocol/type-system-web#PropertyType" LinkType: "@blockprotocol/type-system-web#LinkType" EntityType: "@blockprotocol/type-system-web#EntityType" + DataTypeWithoutId: "@hashintel/hash-shared/graphql/types#DataTypeWithoutId" + PropertyTypeWithoutId: "@hashintel/hash-shared/graphql/types#PropertyTypeWithoutId" + EntityTypeWithoutId: "@hashintel/hash-shared/graphql/types#EntityTypeWithoutId" + LinkTypeWithoutId: "@hashintel/hash-shared/graphql/types#LinkTypeWithoutId" TextToken: "@hashintel/hash-shared/graphql/types#TextToken" Date: string UnknownEntityProperties: "@hashintel/hash-shared/graphql/types#UnknownEntityProperties" diff --git a/packages/hash/api/src/graph/workspace-types.ts b/packages/hash/api/src/graph/workspace-types.ts index 3ea203a4d5a..5a1e1a65734 100644 --- a/packages/hash/api/src/graph/workspace-types.ts +++ b/packages/hash/api/src/graph/workspace-types.ts @@ -1,105 +1,261 @@ import { Logger } from "@hashintel/hash-backend-utils/logger"; -import { AxiosError } from "axios"; -import { PropertyType, EntityType } from "@blockprotocol/type-system-web"; +import { WORKSPACE_ACCOUNT_SHORTNAME } from "@hashintel/hash-backend-utils/system"; import { GraphApi } from "@hashintel/hash-graph-client"; - -import { - DataTypeModel, - EntityTypeModel, - PropertyTypeModel, - AccountFields, - userEntityType, - orgEntityType, - OrgPropertyTypes, - UserPropertyTypes, -} from "../model"; import { logger } from "../logger"; -import { - primitiveDataTypeVersionedUris, - workspaceAccountId, -} from "../model/util"; -const workspacePropertyTypes: PropertyType[] = [ - AccountFields.shortnamePropertyType, - AccountFields.accountIdPropertyType, - ...UserPropertyTypes, - ...OrgPropertyTypes, -]; +import { EntityTypeModel, PropertyTypeModel } from "../model"; +import { propertyTypeInitializer, entityTypeInitializer } from "../model/util"; + +// eslint-disable-next-line import/no-mutable-exports +export let WORKSPACE_TYPES: { + dataType: {}; + propertyType: { + // General account related + accountId: PropertyTypeModel; + shortName: PropertyTypeModel; + + // User-related + email: PropertyTypeModel; + kratosIdentityId: PropertyTypeModel; + preferredName: PropertyTypeModel; + + // Org-related + orgName: PropertyTypeModel; + orgSize: PropertyTypeModel; + orgProvidedInfo: PropertyTypeModel; + }; + entityType: { + user: EntityTypeModel; + org: EntityTypeModel; + }; + linkType: {}; +}; + +// Generate the schema for the org provided info property type +export const orgProvidedInfoPropertyTypeInitializer = async ( + graphApi: GraphApi, +) => { + const orgSizePropertyTypeModel = + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await WORKSPACE_TYPES_INITIALIZERS.propertyType.orgSize(graphApi); + + const orgSizeBaseUri = orgSizePropertyTypeModel.baseUri; + + return propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Organization Provided Info", + possibleValues: [ + { + propertyTypeObjectProperties: { + [orgSizeBaseUri]: { + $ref: orgSizePropertyTypeModel.schema.$id, + }, + }, + }, + ], + })(graphApi); +}; + +// Generate the schema for the org entity type +export const orgEntityTypeInitializer = async (graphApi: GraphApi) => { + /* eslint-disable @typescript-eslint/no-use-before-define */ + const shortnamePropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.shortName(graphApi); + + const accountIdPropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.accountId(graphApi); + + const orgNamePropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.orgName(graphApi); + + const orgProvidedInfoPropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.orgProvidedInfo(graphApi); + /* eslint-enable @typescript-eslint/no-use-before-define */ + + return entityTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Organization", + properties: [ + { + baseUri: shortnamePropertyTypeModel.baseUri, + versionedUri: shortnamePropertyTypeModel.schema.$id, + required: true, + }, + { + baseUri: accountIdPropertyTypeModel.baseUri, + versionedUri: accountIdPropertyTypeModel.schema.$id, + required: true, + }, + { + baseUri: orgNamePropertyTypeModel.baseUri, + versionedUri: orgNamePropertyTypeModel.schema.$id, + required: true, + }, + { + baseUri: orgProvidedInfoPropertyTypeModel.baseUri, + versionedUri: orgProvidedInfoPropertyTypeModel.schema.$id, + required: false, + }, + ], + })(graphApi); +}; + +const accountIdPropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Account ID", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const shortnamePropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Shortname", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const orgNamePropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Organization Name", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const orgSizePropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Organization Size", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const emailPropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Email", + possibleValues: [{ primitiveDataType: "Text" }], +}); -const workspaceEntityTypes: EntityType[] = [userEntityType, orgEntityType]; +const kratosIdentityIdPropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Kratos Identity ID", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const preferredNamePropertyTypeInitializer = propertyTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "Preferred Name", + possibleValues: [{ primitiveDataType: "Text" }], +}); + +const userEntityTypeInitializer = async (graphApi: GraphApi) => { + /* eslint-disable @typescript-eslint/no-use-before-define */ + const shortnamePropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.shortName(graphApi); + + const emailPropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.email(graphApi); + + const kratosIdentityIdPropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.kratosIdentityId(graphApi); + const accountIdPropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.accountId(graphApi); + + const preferredNamePropertyTypeModel = + await WORKSPACE_TYPES_INITIALIZERS.propertyType.preferredName(graphApi); + /* eslint-enable @typescript-eslint/no-use-before-define */ + + return entityTypeInitializer({ + namespace: WORKSPACE_ACCOUNT_SHORTNAME, + title: "User", + properties: [ + { + baseUri: shortnamePropertyTypeModel.baseUri, + versionedUri: shortnamePropertyTypeModel.schema.$id, + }, + { + baseUri: emailPropertyTypeModel.baseUri, + versionedUri: emailPropertyTypeModel.schema.$id, + required: true, + array: { minItems: 1 }, + }, + { + baseUri: kratosIdentityIdPropertyTypeModel.baseUri, + versionedUri: kratosIdentityIdPropertyTypeModel.schema.$id, + required: true, + }, + { + baseUri: accountIdPropertyTypeModel.baseUri, + versionedUri: accountIdPropertyTypeModel.schema.$id, + required: true, + }, + { + baseUri: preferredNamePropertyTypeModel.baseUri, + versionedUri: preferredNamePropertyTypeModel.schema.$id, + required: true, + }, + ], + })(graphApi); +}; + +type LazyPromise = (graphApi: GraphApi) => Promise; + +type FlattenAndPromisify = { + [K in keyof T]: T[K] extends object + ? { [I in keyof T[K]]: LazyPromise } + : never; +}; + +export const WORKSPACE_TYPES_INITIALIZERS: FlattenAndPromisify< + typeof WORKSPACE_TYPES +> = { + dataType: {}, + propertyType: { + accountId: accountIdPropertyTypeInitializer, + shortName: shortnamePropertyTypeInitializer, + + email: emailPropertyTypeInitializer, + kratosIdentityId: kratosIdentityIdPropertyTypeInitializer, + preferredName: preferredNamePropertyTypeInitializer, + + orgName: orgNamePropertyTypeInitializer, + orgSize: orgSizePropertyTypeInitializer, + orgProvidedInfo: orgProvidedInfoPropertyTypeInitializer, + }, + entityType: { + user: userEntityTypeInitializer, + org: orgEntityTypeInitializer, + }, + linkType: {}, +}; /** - * A script that ensures the required primitive data types and workspace types - * have been created in the graph. + * Ensures the required workspace types have been created in the graph. */ export const ensureWorkspaceTypesExist = async (params: { graphApi: GraphApi; logger: Logger; }) => { const { graphApi } = params; + logger.debug("Ensuring Workspace system types exist"); - // First let's ensure the required data types are in the datastore - await Promise.all( - Object.entries(primitiveDataTypeVersionedUris).map( - async ([title, versionedUri]) => { - const dataType = await DataTypeModel.get(graphApi, { - versionedUri, - }).catch((error: AxiosError) => - error.response?.status === 404 ? null : Promise.reject(error), - ); - - if (!dataType) { - throw new Error( - `Primitive data type "${title}" with versioned URI "${versionedUri}" not found`, - ); - } - }, - ), - ); - - // Next, let's ensure all workspace property types have been created - // This is done sequentially as property types might reference other property - // types + // Create workspace types if they don't already exist /** * @todo Use transactional primitive/bulk insert to be able to do this in parallel * see the following task: * https://app.asana.com/0/1201095311341924/1202573572594586/f */ - for (const schema of workspacePropertyTypes) { - const { $id: versionedUri } = schema; - const existingPropertyType = await PropertyTypeModel.get(graphApi, { - versionedUri, - }).catch((error: AxiosError) => - error.response?.status === 404 ? null : Promise.reject(error), - ); - - if (!existingPropertyType) { - await PropertyTypeModel.create(graphApi, { - accountId: workspaceAccountId, - schema, - }); - logger.info(`Created property type with versioned URI "${versionedUri}"`); + + const initializedWorkspaceTypes: any = {}; + + // eslint-disable-next-line guard-for-in + for (const typeKind in WORKSPACE_TYPES_INITIALIZERS) { + initializedWorkspaceTypes[typeKind] = {}; + + const inner = + WORKSPACE_TYPES_INITIALIZERS[ + typeKind as keyof typeof WORKSPACE_TYPES_INITIALIZERS + ]; + for (const [key, typeInitializer] of Object.entries(inner)) { + logger.debug(`Checking Workspace system type: [${key}] exists`); + const model = await typeInitializer(graphApi); + initializedWorkspaceTypes[typeKind][key] = model; } } - // Finally, let's ensure all workspace entity types have been created - await Promise.all( - workspaceEntityTypes.map(async (schema) => { - const { $id: versionedUri } = schema; - const existingEntityType = await EntityTypeModel.get(graphApi, { - versionedUri, - }).catch((error: AxiosError) => - error.response?.status === 404 ? null : Promise.reject(error), - ); - - if (!existingEntityType) { - await EntityTypeModel.create(graphApi, { - accountId: workspaceAccountId, - schema, - }); - - logger.info(`Created entity type with versioned URI "${versionedUri}"`); - } - }), - ); + WORKSPACE_TYPES = initializedWorkspaceTypes; }; diff --git a/packages/hash/api/src/graphql/resolvers/ontology/entity-type.ts b/packages/hash/api/src/graphql/resolvers/ontology/entity-type.ts index 5a05b4f0d69..ecf097992ff 100644 --- a/packages/hash/api/src/graphql/resolvers/ontology/entity-type.ts +++ b/packages/hash/api/src/graphql/resolvers/ontology/entity-type.ts @@ -24,13 +24,8 @@ export const createEntityType: ResolverFn< const createdEntityTypeModel = await EntityTypeModel.create(graphApi, { accountId: accountId ?? user.getAccountId(), schema: entityType, - }).catch((err: AxiosError) => { - const msg = - err.response?.status === 409 - ? `Entity type with the same URI already exists. [URI=${entityType.$id}]` - : `Couldn't create entity type.`; - - throw new ApolloError(msg, "CREATION_ERROR"); + }).catch((err) => { + throw new ApolloError(err, "CREATION_ERROR"); }); return entityTypeModelToGQL(createdEntityTypeModel); diff --git a/packages/hash/api/src/graphql/resolvers/ontology/link-type.ts b/packages/hash/api/src/graphql/resolvers/ontology/link-type.ts index ca4ad4f6600..2bec906a60c 100644 --- a/packages/hash/api/src/graphql/resolvers/ontology/link-type.ts +++ b/packages/hash/api/src/graphql/resolvers/ontology/link-type.ts @@ -24,13 +24,8 @@ export const createLinkType: ResolverFn< const createdLinkTypeModel = await LinkTypeModel.create(graphApi, { accountId: accountId ?? user.getAccountId(), schema: linkType, - }).catch((err: AxiosError) => { - const msg = - err.response?.status === 409 - ? `Link type with the same URI already exists. [URI=${linkType.$id}]` - : `Couldn't create link type.`; - - throw new ApolloError(msg, "CREATION_ERROR"); + }).catch((err) => { + throw new ApolloError(err, "CREATION_ERROR"); }); return linkTypeModelToGQL(createdLinkTypeModel); diff --git a/packages/hash/api/src/graphql/resolvers/ontology/property-type.ts b/packages/hash/api/src/graphql/resolvers/ontology/property-type.ts index 9aa74c4d648..1409465b2e9 100644 --- a/packages/hash/api/src/graphql/resolvers/ontology/property-type.ts +++ b/packages/hash/api/src/graphql/resolvers/ontology/property-type.ts @@ -24,13 +24,8 @@ export const createPropertyType: ResolverFn< const createdPropertyTypeModel = await PropertyTypeModel.create(graphApi, { accountId: accountId ?? user.getAccountId(), schema: propertyType, - }).catch((err: AxiosError) => { - const msg = - err.response?.status === 409 - ? `Property type with the same URI already exists. [URI=${propertyType.$id}]` - : `Couldn't create property type.`; - - throw new ApolloError(msg, "CREATION_ERROR"); + }).catch((err) => { + throw new ApolloError(err, "CREATION_ERROR"); }); return propertyTypeModelToGQL(createdPropertyTypeModel); diff --git a/packages/hash/api/src/graphql/typeDefs/ontology/data-type.typedef.ts b/packages/hash/api/src/graphql/typeDefs/ontology/data-type.typedef.ts index 6b1934677ef..0d8ba60436e 100644 --- a/packages/hash/api/src/graphql/typeDefs/ontology/data-type.typedef.ts +++ b/packages/hash/api/src/graphql/typeDefs/ontology/data-type.typedef.ts @@ -2,6 +2,7 @@ import { gql } from "apollo-server-express"; export const dataTypeTypedef = gql` scalar DataType + scalar DataTypeWithoutId type PersistedDataType { """ @@ -33,7 +34,7 @@ export const dataTypeTypedef = gql` # The following mutations should not be exposed until user defined data types # have been described and specified as an RFC. # extend type Mutation { - # createDataType(accountId: ID!, dataType: DataType!): PersistedDataType! - # updateDataType(accountId: ID!, dataType: DataType!): PersistedDataType! + # createDataType(accountId: ID!, dataType: DataTypeWithoutId!): PersistedDataType! + # updateDataType(accountId: ID!, dataType: DataTypeWithoutId!): PersistedDataType! # } `; diff --git a/packages/hash/api/src/graphql/typeDefs/ontology/entity-type.typedef.ts b/packages/hash/api/src/graphql/typeDefs/ontology/entity-type.typedef.ts index 24296c36ee6..4513b8de987 100644 --- a/packages/hash/api/src/graphql/typeDefs/ontology/entity-type.typedef.ts +++ b/packages/hash/api/src/graphql/typeDefs/ontology/entity-type.typedef.ts @@ -2,6 +2,7 @@ import { gql } from "apollo-server-express"; export const entityTypeTypedef = gql` scalar EntityType + scalar EntityTypeWithoutId type PersistedEntityType { """ @@ -41,7 +42,7 @@ export const entityTypeTypedef = gql` accountId refers to the account to create the entity type in. """ accountId: ID - entityType: EntityType! + entityType: EntityTypeWithoutId! ): PersistedEntityType! """ @@ -59,7 +60,7 @@ export const entityTypeTypedef = gql` """ New entity type schema contents to be used. """ - updatedEntityType: EntityType! + updatedEntityType: EntityTypeWithoutId! ): PersistedEntityType! } `; diff --git a/packages/hash/api/src/graphql/typeDefs/ontology/link-type.typedef.ts b/packages/hash/api/src/graphql/typeDefs/ontology/link-type.typedef.ts index 733b2cea1d8..18c52b3dc0e 100644 --- a/packages/hash/api/src/graphql/typeDefs/ontology/link-type.typedef.ts +++ b/packages/hash/api/src/graphql/typeDefs/ontology/link-type.typedef.ts @@ -2,6 +2,7 @@ import { gql } from "apollo-server-express"; export const linkTypeTypedef = gql` scalar LinkType + scalar LinkTypeWithoutId type PersistedLinkType { """ @@ -39,7 +40,7 @@ export const linkTypeTypedef = gql` The id of the account where to create the link type in. Defaults to the account id of the current user. """ accountId: ID - linkType: LinkType! + linkType: LinkTypeWithoutId! ): PersistedLinkType! """ @@ -57,7 +58,7 @@ export const linkTypeTypedef = gql` """ New link type schema contents to be used. """ - updatedLinkType: LinkType! + updatedLinkType: LinkTypeWithoutId! ): PersistedLinkType! } `; diff --git a/packages/hash/api/src/graphql/typeDefs/ontology/property-type.typedef.ts b/packages/hash/api/src/graphql/typeDefs/ontology/property-type.typedef.ts index f62081555f2..e5c211e5385 100644 --- a/packages/hash/api/src/graphql/typeDefs/ontology/property-type.typedef.ts +++ b/packages/hash/api/src/graphql/typeDefs/ontology/property-type.typedef.ts @@ -2,6 +2,7 @@ import { gql } from "apollo-server-express"; export const propertyTypeTypedef = gql` scalar PropertyType + scalar PropertyTypeWithoutId type PersistedPropertyType { """ @@ -43,7 +44,7 @@ export const propertyTypeTypedef = gql` The id of the account where to create the property type in. Defaults to the account id of the current user. """ accountId: ID - propertyType: PropertyType! + propertyType: PropertyTypeWithoutId! ): PersistedPropertyType! """ @@ -61,7 +62,7 @@ export const propertyTypeTypedef = gql` """ New property type schema contents to be used. """ - updatedPropertyType: PropertyType! + updatedPropertyType: PropertyTypeWithoutId! ): PersistedPropertyType! } `; diff --git a/packages/hash/api/src/index.ts b/packages/hash/api/src/index.ts index b3a6f5d98aa..173da4c6ed2 100644 --- a/packages/hash/api/src/index.ts +++ b/packages/hash/api/src/index.ts @@ -15,13 +15,14 @@ import { OpenSearch } from "@hashintel/hash-backend-utils/search/opensearch"; import { GracefulShutdown } from "@hashintel/hash-backend-utils/shutdown"; import { RedisQueueExclusiveConsumer } from "@hashintel/hash-backend-utils/queue/redis"; import { AsyncRedisClient } from "@hashintel/hash-backend-utils/redis"; - import { monorepoRootDir, waitOnResource, } from "@hashintel/hash-backend-utils/environment"; + import setupAuth from "./auth"; import { RedisCache } from "./cache"; +import { ensureWorkspaceTypesExist } from "./graph/workspace-types"; import { createCollabApp } from "./collab/collabApp"; import { AwsSesEmailTransporter, @@ -43,7 +44,6 @@ import { getAwsRegion } from "./lib/aws-config"; import { setupTelemetry } from "./telemetry/snowplow-setup"; import { connectToTaskExecutor } from "./task-execution"; import { createGraphClient } from "./graph"; -import { ensureWorkspaceTypesExist } from "./graph/workspace-types"; import { ensureDevUsersAreSeeded } from "./auth/seed-dev-users"; const shutdown = new GracefulShutdown(logger, "SIGINT", "SIGTERM"); diff --git a/packages/hash/api/src/model/knowledge/account.fields.ts b/packages/hash/api/src/model/knowledge/account.fields.ts index dc0ea268c17..e7e7166dfcd 100644 --- a/packages/hash/api/src/model/knowledge/account.fields.ts +++ b/packages/hash/api/src/model/knowledge/account.fields.ts @@ -1,37 +1,6 @@ import { OrgModel, UserModel } from ".."; import { GraphApi } from "../../graph"; -import { - generateSchemaBaseUri, - generateWorkspacePropertyTypeSchema, - RESTRICTED_SHORTNAMES, - workspaceTypesNamespaceUri, -} from "../util"; - -// AccountId -// Generate the schema for the account id property type -export const accountIdPropertyType = generateWorkspacePropertyTypeSchema({ - title: "Account ID", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const accountIdBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: accountIdPropertyType.title, -}); - -// Shortname -// Generate the schema for the shortname property type -export const shortnamePropertyType = generateWorkspacePropertyTypeSchema({ - title: "Shortname", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const shortnameBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: shortnamePropertyType.title, -}); +import { RESTRICTED_SHORTNAMES } from "../util"; // Validations for shortnames /** diff --git a/packages/hash/api/src/model/knowledge/org.model.ts b/packages/hash/api/src/model/knowledge/org.model.ts index bd1a5cef57e..f02f322cd90 100644 --- a/packages/hash/api/src/model/knowledge/org.model.ts +++ b/packages/hash/api/src/model/knowledge/org.model.ts @@ -6,90 +6,8 @@ import { AccountFields, EntityTypeModel, } from ".."; -import { - generateSchemaBaseUri, - generateWorkspaceEntityTypeSchema, - generateWorkspacePropertyTypeSchema, - workspaceAccountId, - workspaceTypesNamespaceUri, -} from "../util"; - -// Generate the schema for the organization name property type -export const orgNamePropertyType = generateWorkspacePropertyTypeSchema({ - title: "Organization Name", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const orgNamedBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: orgNamePropertyType.title, -}); - -// Generate the schema for the org size property type -export const orgSizePropertyType = generateWorkspacePropertyTypeSchema({ - title: "Organization Size", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const orgSizeBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: orgSizePropertyType.title, -}); - -// Generate the schema for the org provided info property type -export const orgProvidedInfoPropertyType = generateWorkspacePropertyTypeSchema({ - title: "Organization Provided Info", - possibleValues: [ - { - propertyTypeObject: { - [orgSizeBaseUri]: { $ref: orgSizePropertyType.$id }, - }, - }, - ], -}); - -export const orgProvidedInfoBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: orgProvidedInfoPropertyType.title, -}); - -export const OrgPropertyTypes = [ - orgNamePropertyType, - orgSizePropertyType, - orgProvidedInfoPropertyType, -] as const; - -// Generate the schema for the org entity type -export const orgEntityType = generateWorkspaceEntityTypeSchema({ - title: "Organization", - properties: [ - { - baseUri: AccountFields.shortnameBaseUri, - versionedUri: AccountFields.shortnamePropertyType.$id, - required: true, - }, - { - baseUri: AccountFields.accountIdBaseUri, - versionedUri: AccountFields.accountIdPropertyType.$id, - required: true, - }, - { - baseUri: orgNamedBaseUri, - versionedUri: orgNamePropertyType.$id, - required: true, - }, - { - baseUri: orgProvidedInfoBaseUri, - versionedUri: orgProvidedInfoPropertyType.$id, - required: false, - }, - ], -}); - -const orgEntityTypeVersionedUri = orgEntityType.$id; +import { workspaceAccountId } from "../util"; +import { WORKSPACE_TYPES } from "../../graph/workspace-types"; /** * @todo revisit organization size provided info. These constant strings could @@ -133,12 +51,13 @@ export default class extends EntityModel { const { data: orgAccountId } = await graphApi.createAccountId(); const properties: object = { - [AccountFields.accountIdBaseUri]: orgAccountId, - [AccountFields.shortnameBaseUri]: shortname, - [orgNamedBaseUri]: name, - [orgProvidedInfoBaseUri]: providedInfo + [WORKSPACE_TYPES.propertyType.accountId.baseUri]: orgAccountId, + [WORKSPACE_TYPES.propertyType.shortName.baseUri]: shortname, + [WORKSPACE_TYPES.propertyType.orgName.baseUri]: name, + [WORKSPACE_TYPES.propertyType.orgProvidedInfo.baseUri]: providedInfo ? { - [orgSizeBaseUri]: providedInfo.orgSize, + [WORKSPACE_TYPES.propertyType.orgSize.baseUri]: + providedInfo.orgSize, } : undefined, }; @@ -166,8 +85,10 @@ export default class extends EntityModel { * Get the system Organization entity type. */ static async getOrgEntityType(graphApi: GraphApi) { + const versionedUri = WORKSPACE_TYPES.entityType.org.schema.$id; + return await EntityTypeModel.get(graphApi, { - versionedUri: orgEntityTypeVersionedUri, + versionedUri, }); } @@ -180,6 +101,8 @@ export default class extends EntityModel { graphApi: GraphApi, params: { shortname: string }, ): Promise { + const versionedUri = WORKSPACE_TYPES.entityType.org.schema.$id; + /** @todo: use upcoming Graph API method to filter entities in the datastore */ const allEntities = await EntityModel.getAllLatest(graphApi, { accountId: workspaceAccountId, @@ -187,8 +110,7 @@ export default class extends EntityModel { const matchingOrg = allEntities .filter( - ({ entityTypeModel }) => - entityTypeModel.schema.$id === orgEntityTypeVersionedUri, + ({ entityTypeModel }) => entityTypeModel.schema.$id === versionedUri, ) .map((entityModel) => new OrgModel(entityModel)) .find((org) => org.getShortname() === params.shortname); @@ -197,11 +119,15 @@ export default class extends EntityModel { } getAccountId(): string { - return (this.properties as any)[AccountFields.accountIdBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.accountId.baseUri + ]; } getShortname(): string { - return (this.properties as any)[AccountFields.shortnameBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.shortName.baseUri + ]; } /** @@ -232,14 +158,16 @@ export default class extends EntityModel { } return await this.updateProperty(graphApi, { - propertyTypeBaseUri: AccountFields.shortnameBaseUri, + propertyTypeBaseUri: WORKSPACE_TYPES.propertyType.shortName.baseUri, value: updatedShortname, updatedByAccountId, }).then((updatedEntity) => new OrgModel(updatedEntity)); } getOrgName(): string { - return (this.properties as any)[orgNamedBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.orgName.baseUri + ]; } static orgNameIsInvalid(preferredName: string) { @@ -263,7 +191,7 @@ export default class extends EntityModel { } const updatedEntity = await this.updateProperty(graphApi, { - propertyTypeBaseUri: orgNamedBaseUri, + propertyTypeBaseUri: WORKSPACE_TYPES.propertyType.orgName.baseUri, value: updatedOrgName, updatedByAccountId, }); diff --git a/packages/hash/api/src/model/knowledge/user.model.ts b/packages/hash/api/src/model/knowledge/user.model.ts index e1e6390d2fe..f82e5714b63 100644 --- a/packages/hash/api/src/model/knowledge/user.model.ts +++ b/packages/hash/api/src/model/knowledge/user.model.ts @@ -1,70 +1,22 @@ import { GraphApi } from "@hashintel/hash-graph-client"; +import { AxiosError } from "axios"; import { EntityModel, - EntityModelCreateParams, EntityTypeModel, UserModel, AccountFields, + EntityModelCreateParams, } from ".."; import { adminKratosSdk, KratosUserIdentity, KratosUserIdentityTraits, } from "../../auth/ory-kratos"; -import { - generateSchemaBaseUri, - generateWorkspaceEntityTypeSchema, - generateWorkspacePropertyTypeSchema, - workspaceAccountId, - workspaceTypesNamespaceUri, -} from "../util"; +import { WORKSPACE_TYPES } from "../../graph/workspace-types"; +import { workspaceAccountId } from "../util"; type QualifiedEmail = { address: string; verified: boolean; primary: boolean }; -// Generate the schema for the email property type -export const emailPropertyType = generateWorkspacePropertyTypeSchema({ - title: "Email", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const emailBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: emailPropertyType.title, -}); - -// Generate the schema for the kratos identity property type -export const kratosIdentityIdPropertyType = generateWorkspacePropertyTypeSchema( - { - title: "Kratos Identity ID", - possibleValues: [{ primitiveDataType: "Text" }], - }, -); - -export const kratosIdentityIdBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: kratosIdentityIdPropertyType.title, -}); - -// Generate the schema for the preferred name property type -export const preferredNamePropertyType = generateWorkspacePropertyTypeSchema({ - title: "Preferred Name", - possibleValues: [{ primitiveDataType: "Text" }], -}); - -export const preferredNameBaseUri = generateSchemaBaseUri({ - namespaceUri: workspaceTypesNamespaceUri, - kind: "propertyType", - title: preferredNamePropertyType.title, -}); - -export const UserPropertyTypes = [ - emailPropertyType, - kratosIdentityIdPropertyType, - preferredNamePropertyType, -]; - type UserModelCreateParams = Omit< EntityModelCreateParams, "properties" | "entityTypeModel" | "accountId" @@ -73,44 +25,34 @@ type UserModelCreateParams = Omit< kratosIdentityId: string; }; -// Generate the schema for the user entity type -export const userEntityType = generateWorkspaceEntityTypeSchema({ - title: "User", - properties: [ - { - baseUri: AccountFields.shortnameBaseUri, - versionedUri: AccountFields.shortnamePropertyType.$id, - }, - { - baseUri: emailBaseUri, - versionedUri: emailPropertyType.$id, - required: true, - array: { minItems: 1 }, - }, - { - baseUri: kratosIdentityIdBaseUri, - versionedUri: kratosIdentityIdPropertyType.$id, - required: true, - }, - { - baseUri: AccountFields.accountIdBaseUri, - versionedUri: AccountFields.accountIdPropertyType.$id, - required: true, - }, - { - baseUri: preferredNameBaseUri, - versionedUri: preferredNamePropertyType.$id, - required: true, - }, - ], -}); - -const userEntityTypeVersionedUri = userEntityType.$id; - /** * @class {@link UserModel} */ export default class extends EntityModel { + static async getUserByAccountId( + graphApi: GraphApi, + params: { accountId: string }, + ): Promise { + /** + * @todo: This method and `getUserByEntityId` is confusing. Should be fixed as part of: + * https://app.asana.com/0/1200211978612931/1202937382769276/f + */ + const allEntities = await EntityModel.getAllLatest(graphApi, { + accountId: workspaceAccountId, + }); + + const matchingUser = allEntities + .filter( + ({ entityTypeModel }) => + entityTypeModel.schema.$id === + WORKSPACE_TYPES.entityType.user.schema.$id, + ) + .map((entityModel) => new UserModel(entityModel)) + .find((user) => user.getAccountId() === params.accountId); + + return matchingUser ?? null; + } + /** * Get a workspace user entity by their entityId. * @@ -125,6 +67,10 @@ export default class extends EntityModel { const entityModel = await EntityModel.getLatest(graphApi, { accountId: workspaceAccountId, entityId, + }).catch((err: AxiosError) => { + throw new Error( + `failed to get user entity with id ${entityId}: ${err.code} ${err.response?.data}`, + ); }); return new UserModel(entityModel); @@ -150,7 +96,8 @@ export default class extends EntityModel { const matchingUser = allEntities .filter( ({ entityTypeModel }) => - entityTypeModel.schema.$id === userEntityTypeVersionedUri, + entityTypeModel.schema.$id === + WORKSPACE_TYPES.entityType.user.schema.$id, ) .map((entityModel) => new UserModel(entityModel)) .find((user) => user.getShortname() === params.shortname); @@ -178,7 +125,8 @@ export default class extends EntityModel { const matchingUser = allEntities .filter( ({ entityTypeModel }) => - entityTypeModel.schema.$id === userEntityTypeVersionedUri, + entityTypeModel.schema.$id === + WORKSPACE_TYPES.entityType.user.schema.$id, ) .map((entityModel) => new UserModel(entityModel)) .find((user) => user.getKratosIdentityId() === params.kratosIdentityId); @@ -191,7 +139,7 @@ export default class extends EntityModel { */ static async getUserEntityType(graphApi: GraphApi): Promise { return await EntityTypeModel.get(graphApi, { - versionedUri: userEntityTypeVersionedUri, + versionedUri: WORKSPACE_TYPES.entityType.user.schema.$id, }); } @@ -221,11 +169,11 @@ export default class extends EntityModel { const { data: userAccountId } = await graphApi.createAccountId(); const properties: object = { - [emailBaseUri]: emails, - [kratosIdentityIdBaseUri]: kratosIdentityId, - [AccountFields.accountIdBaseUri]: userAccountId, - [AccountFields.shortnameBaseUri]: undefined, - [preferredNameBaseUri]: undefined, + [WORKSPACE_TYPES.propertyType.email.baseUri]: emails, + [WORKSPACE_TYPES.propertyType.kratosIdentityId.baseUri]: kratosIdentityId, + [WORKSPACE_TYPES.propertyType.accountId.baseUri]: userAccountId, + [WORKSPACE_TYPES.propertyType.shortName.baseUri]: undefined, + [WORKSPACE_TYPES.propertyType.preferredName.baseUri]: undefined, }; const entityTypeModel = await UserModel.getUserEntityType(graphApi); @@ -285,7 +233,9 @@ export default class extends EntityModel { } async getQualifiedEmails(): Promise { - const emails: string[] = (this.properties as any)[emailBaseUri]; + const emails: string[] = (this.properties as any)[ + WORKSPACE_TYPES.propertyType.email.baseUri + ]; const kratosIdentity = await this.getKratosIdentity(); @@ -312,11 +262,20 @@ export default class extends EntityModel { } getEmails(): string[] { - return (this.properties as any)[emailBaseUri]; + return (this.properties as any)[WORKSPACE_TYPES.propertyType.email.baseUri]; } + /** + * @todo This is only undefined because of Users that are in the process of an uncompleted sign-up flow. + * Otherwise this should be an invariant and always true. We should revisit how uninitialized users are represented + * to avoid things like this: + * + * https://app.asana.com/0/1202805690238892/1202944961125764/f + */ getShortname(): string | undefined { - return (this.properties as any)[AccountFields.shortnameBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.shortName.baseUri + ]; } /** @@ -349,7 +308,7 @@ export default class extends EntityModel { const previousShortname = this.getShortname(); const updatedUser = await this.updateProperty(graphApi, { - propertyTypeBaseUri: AccountFields.shortnameBaseUri, + propertyTypeBaseUri: WORKSPACE_TYPES.propertyType.shortName.baseUri, value: updatedShortname, updatedByAccountId, }).then((updatedEntity) => new UserModel(updatedEntity)); @@ -359,7 +318,7 @@ export default class extends EntityModel { }).catch(async (error) => { // If an error occurred updating the entity, set the property to have the previous shortname await this.updateProperty(graphApi, { - propertyTypeBaseUri: AccountFields.shortnameBaseUri, + propertyTypeBaseUri: WORKSPACE_TYPES.propertyType.shortName.baseUri, value: previousShortname, updatedByAccountId, }); @@ -371,7 +330,9 @@ export default class extends EntityModel { } getPreferredName(): string | undefined { - return (this.properties as any)[preferredNameBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.preferredName.baseUri + ]; } static preferredNameIsInvalid(preferredName: string) { @@ -395,7 +356,7 @@ export default class extends EntityModel { } const updatedEntity = await this.updateProperty(graphApi, { - propertyTypeBaseUri: preferredNameBaseUri, + propertyTypeBaseUri: WORKSPACE_TYPES.propertyType.preferredName.baseUri, value: updatedPreferredName, updatedByAccountId, }); @@ -404,11 +365,15 @@ export default class extends EntityModel { } getKratosIdentityId(): string { - return (this.properties as any)[kratosIdentityIdBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.kratosIdentityId.baseUri + ]; } getAccountId(): string { - return (this.properties as any)[AccountFields.accountIdBaseUri]; + return (this.properties as any)[ + WORKSPACE_TYPES.propertyType.accountId.baseUri + ]; } getInfoProvidedAtSignup(): any { diff --git a/packages/hash/api/src/model/ontology/data-type.model.ts b/packages/hash/api/src/model/ontology/data-type.model.ts index 33e0fba9add..e9d40f8047f 100644 --- a/packages/hash/api/src/model/ontology/data-type.model.ts +++ b/packages/hash/api/src/model/ontology/data-type.model.ts @@ -1,9 +1,11 @@ -import { GraphApi, UpdateDataTypeRequest } from "@hashintel/hash-graph-client"; +import { AxiosError } from "axios"; +import { GraphApi, UpdateDataTypeRequest } from "@hashintel/hash-graph-client"; import { DataType } from "@blockprotocol/type-system-web"; +import { WORKSPACE_ACCOUNT_SHORTNAME } from "@hashintel/hash-backend-utils/system"; -import { DataTypeModel } from "../index"; -import { incrementVersionedId } from "../util"; +import { DataTypeModel, UserModel } from "../index"; +import { generateSchemaUri, workspaceAccountId } from "../util"; type DataTypeModelConstructorArgs = { accountId: string; @@ -39,13 +41,51 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: DataType; + // we have to manually specify this type because of 'intended' limitations of `Omit` with extended Record types: + // https://github.com/microsoft/TypeScript/issues/50638 + // this is needed for as long as DataType extends Record + schema: Pick & + Record; }, ): Promise { - const { data: identifier } = await graphApi.createDataType(params); + /** @todo - get rid of this hack for the root account */ + const namespace = + params.accountId === workspaceAccountId + ? WORKSPACE_ACCOUNT_SHORTNAME + : ( + await UserModel.getUserByAccountId(graphApi, { + accountId: params.accountId, + }) + )?.getShortname(); + + if (namespace == null) { + throw new Error( + `failed to get namespace for account: ${params.accountId}`, + ); + } + + const dataTypeUri = generateSchemaUri({ + namespace, + kind: "data-type", + title: params.schema.title, + }); + const fullDataType = { $id: dataTypeUri, ...params.schema }; + + const { data: identifier } = await graphApi + .createDataType({ + accountId: params.accountId, + schema: fullDataType, + }) + .catch((err: AxiosError) => { + throw new Error( + err.response?.status === 409 + ? `data type with the same URI already exists. [URI=${fullDataType.$id}]` + : `[${err.code}] couldn't create data type: ${err.response?.data}.`, + ); + }); return new DataTypeModel({ - schema: params.schema, + schema: fullDataType, accountId: identifier.createdBy, }); } @@ -137,32 +177,25 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: DataType; + // we have to manually specify this type because of 'intended' limitations of `Omit` with extended Record types: + // https://github.com/microsoft/TypeScript/issues/50638 + // this is needed for as long as DataType extends Record + schema: Pick & + Record; }, ): Promise { - const newVersionedId = incrementVersionedId(this.schema.$id); - const { accountId, schema } = params; + const updateArguments: UpdateDataTypeRequest = { accountId, - schema: { ...schema, $id: newVersionedId }, + typeToUpdate: this.schema.$id, + schema, }; const { data: identifier } = await graphApi.updateDataType(updateArguments); return new DataTypeModel({ - /** - * @todo and a warning, these type casts are here to compensate for - * the differences between the Graph API package and the - * type system package. - * - * The type system package can be considered the source of truth in - * terms of the shape of values returned from the API, but the API - * client is unable to be given as type package types - it generates - * its own types. - * https://app.asana.com/0/1202805690238892/1202892835843657/f - */ - schema: updateArguments.schema as DataType, + schema: { ...schema, $id: identifier.uri }, accountId: identifier.createdBy, }); } diff --git a/packages/hash/api/src/model/ontology/entity-type.model.ts b/packages/hash/api/src/model/ontology/entity-type.model.ts index c1506c14d54..1fb453a7e9f 100644 --- a/packages/hash/api/src/model/ontology/entity-type.model.ts +++ b/packages/hash/api/src/model/ontology/entity-type.model.ts @@ -1,11 +1,19 @@ +import { AxiosError } from "axios"; + import { EntityType } from "@blockprotocol/type-system-web"; import { GraphApi, UpdateEntityTypeRequest, } from "@hashintel/hash-graph-client"; +import { WORKSPACE_ACCOUNT_SHORTNAME } from "@hashintel/hash-backend-utils/system"; -import { EntityTypeModel, PropertyTypeModel, LinkTypeModel } from "../index"; -import { incrementVersionedId } from "../util"; +import { + EntityTypeModel, + PropertyTypeModel, + LinkTypeModel, + UserModel, +} from "../index"; +import { generateSchemaUri, workspaceAccountId } from "../util"; export type EntityTypeModelConstructorParams = { accountId: string; @@ -14,7 +22,7 @@ export type EntityTypeModelConstructorParams = { export type EntityTypeModelCreateParams = { accountId: string; - schema: EntityType; + schema: Omit; }; /** @@ -40,10 +48,44 @@ export default class { graphApi: GraphApi, params: EntityTypeModelCreateParams, ): Promise { - const { data: identifier } = await graphApi.createEntityType(params); + /** @todo - get rid of this hack for the root account */ + const namespace = + params.accountId === workspaceAccountId + ? WORKSPACE_ACCOUNT_SHORTNAME + : ( + await UserModel.getUserByAccountId(graphApi, { + accountId: params.accountId, + }) + )?.getShortname(); + + if (namespace == null) { + throw new Error( + `failed to get namespace for account: ${params.accountId}`, + ); + } + + const entityTypeUri = generateSchemaUri({ + namespace, + kind: "entity-type", + title: params.schema.title, + }); + const fullEntityType = { $id: entityTypeUri, ...params.schema }; + + const { data: identifier } = await graphApi + .createEntityType({ + accountId: params.accountId, + schema: fullEntityType, + }) + .catch((err: AxiosError) => { + throw new Error( + err.response?.status === 409 + ? `entity type with the same URI already exists. [URI=${fullEntityType.$id}]` + : `[${err.code}] couldn't create entity type: ${err.response?.data}.`, + ); + }); return new EntityTypeModel({ - schema: params.schema, + schema: fullEntityType, accountId: identifier.createdBy, }); } @@ -130,15 +172,14 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: EntityType; + schema: Omit; }, ): Promise { - const newVersionedId = incrementVersionedId(this.schema.$id); - const { accountId, schema } = params; const updateArguments: UpdateEntityTypeRequest = { accountId, - schema: { ...schema, $id: newVersionedId }, + typeToUpdate: this.schema.$id, + schema, }; const { data: identifier } = await graphApi.updateEntityType( @@ -146,18 +187,7 @@ export default class { ); return new EntityTypeModel({ - /** - * @todo and a warning, these type casts are here to compensate for - * the differences between the Graph API package and the - * type system package. - * - * The type system package can be considered the source of truth in - * terms of the shape of values returned from the API, but the API - * client is unable to be given as type package types - it generates - * its own types. - * https://app.asana.com/0/1202805690238892/1202892835843657/f - */ - schema: updateArguments.schema as EntityType, + schema: { ...schema, $id: identifier.uri }, accountId: identifier.createdBy, }); } diff --git a/packages/hash/api/src/model/ontology/link-type.model.ts b/packages/hash/api/src/model/ontology/link-type.model.ts index ea5b4b059ab..22640ce076b 100644 --- a/packages/hash/api/src/model/ontology/link-type.model.ts +++ b/packages/hash/api/src/model/ontology/link-type.model.ts @@ -1,8 +1,11 @@ +import { AxiosError } from "axios"; + import { LinkType } from "@blockprotocol/type-system-web"; import { GraphApi, UpdateLinkTypeRequest } from "@hashintel/hash-graph-client"; +import { WORKSPACE_ACCOUNT_SHORTNAME } from "@hashintel/hash-backend-utils/system"; -import { LinkTypeModel } from "../index"; -import { incrementVersionedId } from "../util"; +import { LinkTypeModel, UserModel } from "../index"; +import { generateSchemaUri, workspaceAccountId } from "../util"; type LinkTypeModelConstructorParams = { accountId: string; @@ -32,13 +35,47 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: LinkType; + schema: Omit; }, ): Promise { - const { data: identifier } = await graphApi.createLinkType(params); + /** @todo - get rid of this hack for the root account */ + const namespace = + params.accountId === workspaceAccountId + ? WORKSPACE_ACCOUNT_SHORTNAME + : ( + await UserModel.getUserByAccountId(graphApi, { + accountId: params.accountId, + }) + )?.getShortname(); + + if (namespace == null) { + throw new Error( + `failed to get namespace for account: ${params.accountId}`, + ); + } + + const linkTypeUri = generateSchemaUri({ + namespace, + kind: "link-type", + title: params.schema.title, + }); + const fullLinkType = { $id: linkTypeUri, ...params.schema }; + + const { data: identifier } = await graphApi + .createLinkType({ + accountId: params.accountId, + schema: fullLinkType, + }) + .catch((err: AxiosError) => { + throw new Error( + err.response?.status === 409 + ? `link type with the same URI already exists. [URI=${fullLinkType.$id}]` + : `[${err.code}] couldn't create link type: ${err.response?.data}.`, + ); + }); return new LinkTypeModel({ - schema: params.schema, + schema: fullLinkType, accountId: identifier.createdBy, }); } @@ -102,21 +139,20 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: LinkType; + schema: Omit; }, ): Promise { - const newVersionedId = incrementVersionedId(this.schema.$id); - const { accountId, schema } = params; const updateArguments: UpdateLinkTypeRequest = { accountId, - schema: { ...schema, $id: newVersionedId }, + typeToUpdate: this.schema.$id, + schema, }; const { data: identifier } = await graphApi.updateLinkType(updateArguments); return new LinkTypeModel({ - schema: updateArguments.schema, + schema: { ...schema, $id: identifier.uri }, accountId: identifier.createdBy, }); } diff --git a/packages/hash/api/src/model/ontology/property-type.model.ts b/packages/hash/api/src/model/ontology/property-type.model.ts index 708b7052791..f9ad9100c55 100644 --- a/packages/hash/api/src/model/ontology/property-type.model.ts +++ b/packages/hash/api/src/model/ontology/property-type.model.ts @@ -1,12 +1,14 @@ +import { AxiosError } from "axios"; +import { PropertyType } from "@blockprotocol/type-system-web"; + import { GraphApi, UpdatePropertyTypeRequest, } from "@hashintel/hash-graph-client"; +import { WORKSPACE_ACCOUNT_SHORTNAME } from "@hashintel/hash-backend-utils/system"; -import { PropertyType } from "@blockprotocol/type-system-web"; - -import { PropertyTypeModel } from "../index"; -import { incrementVersionedId } from "../util"; +import { PropertyTypeModel, UserModel } from "../index"; +import { extractBaseUri, generateSchemaUri, workspaceAccountId } from "../util"; type PropertyTypeModelConstructorParams = { accountId: string; @@ -36,13 +38,47 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: PropertyType; + schema: Omit; }, ): Promise { - const { data: identifier } = await graphApi.createPropertyType(params); + /** @todo - get rid of this hack for the root account */ + const namespace = + params.accountId === workspaceAccountId + ? WORKSPACE_ACCOUNT_SHORTNAME + : ( + await UserModel.getUserByAccountId(graphApi, { + accountId: params.accountId, + }) + )?.getShortname(); + + if (namespace == null) { + throw new Error( + `failed to get namespace for account: ${params.accountId}`, + ); + } + + const propertyTypeUri = generateSchemaUri({ + namespace, + kind: "property-type", + title: params.schema.title, + }); + const fullPropertyType = { $id: propertyTypeUri, ...params.schema }; + + const { data: identifier } = await graphApi + .createPropertyType({ + accountId: params.accountId, + schema: fullPropertyType, + }) + .catch((err: AxiosError) => { + throw new Error( + err.response?.status === 409 + ? `property type with the same URI already exists. [URI=${fullPropertyType.$id}]` + : `[${err.code}] couldn't create property type: ${err.response?.data}.`, + ); + }); return new PropertyTypeModel({ - schema: params.schema, + schema: fullPropertyType, accountId: identifier.createdBy, }); } @@ -129,15 +165,14 @@ export default class { graphApi: GraphApi, params: { accountId: string; - schema: PropertyType; + schema: Omit; }, ): Promise { - const newVersionedId = incrementVersionedId(this.schema.$id); - const { accountId, schema } = params; const updateArguments: UpdatePropertyTypeRequest = { accountId, - schema: { ...schema, $id: newVersionedId }, + typeToUpdate: this.schema.$id, + schema, }; const { data: identifier } = await graphApi.updatePropertyType( @@ -145,19 +180,12 @@ export default class { ); return new PropertyTypeModel({ - /** - * @todo and a warning, these type casts are here to compensate for - * the differences between the Graph API package and the - * type system package. - * - * The type system package can be considered the source of truth in - * terms of the shape of values returned from the API, but the API - * client is unable to be given as type package types - it generates - * its own types. - * https://app.asana.com/0/1202805690238892/1202892835843657/f - */ - schema: updateArguments.schema as PropertyType, + schema: { ...schema, $id: identifier.uri }, accountId: identifier.createdBy, }); } + + get baseUri() { + return extractBaseUri(this.schema.$id); + } } diff --git a/packages/hash/api/src/model/util.ts b/packages/hash/api/src/model/util.ts index d96163452b3..aee98be64db 100644 --- a/packages/hash/api/src/model/util.ts +++ b/packages/hash/api/src/model/util.ts @@ -1,11 +1,15 @@ import { - DataType, PropertyType, EntityType, - LinkType, + PropertyValues, + VersionedUri, } from "@blockprotocol/type-system-web"; +import { AxiosError } from "axios"; import slugify from "slugify"; -import { getRequiredEnv } from "../util"; +import { EntityTypeModel, PropertyTypeModel } from "."; +import { GraphApi } from "../graph"; +import { FRONTEND_URL } from "../lib/config"; +import { logger } from "../logger"; /** @todo: enable admins to expand upon restricted shortnames block list */ export const RESTRICTED_SHORTNAMES = [ @@ -68,52 +72,41 @@ export const nilUuid = "00000000-0000-0000-0000-000000000000" as const; */ export const workspaceAccountId = nilUuid; -const workspaceAccountShortname = getRequiredEnv("WORKSPACE_ACCOUNT_SHORTNAME"); +type SchemaKind = "data-type" | "property-type" | "entity-type" | "link-type"; -/** - * @todo: revisit how this URI is defined and obtained as this is a temporary solution - * https://app.asana.com/0/1200211978612931/1202848989198299/f - */ -export const workspaceTypesNamespaceUri = `https://example.com/@${workspaceAccountShortname}/types`; - -export const blockprotocolTypesNamespaceUri = - "https://blockprotocol.org/@blockprotocol/types"; - -type SchemaKind = - | EntityType["kind"] - | PropertyType["kind"] - | DataType["kind"] - | LinkType["kind"]; - -const schemaKindSlugs: Record = { - entityType: "entity-type", - dataType: "data-type", - propertyType: "property-type", - linkType: "link-type", -}; - -/** - * @todo replace with unified type ID generation - * https://app.asana.com/0/1200211978612931/1202848989198299/f - */ const slugifySchemaTitle = (title: string): string => slugify(title, { lower: true }); -export const generateSchemaBaseUri = (params: { - namespaceUri: string; +export const generateSchemaUri = ({ + domain = FRONTEND_URL, + namespace, + kind, + title, +}: { + domain?: string; + namespace: string; kind: SchemaKind; title: string; -}) => - `${params.namespaceUri}/${schemaKindSlugs[params.kind]}/${slugifySchemaTitle( - params.title, - )}/` as const; +}): VersionedUri => + `${domain}/@${namespace}/types/${kind}/${slugifySchemaTitle( + title, + )}/v/1` as const; -export const generateSchemaVersionedUri = (params: { - namespaceUri: string; - kind: SchemaKind; - title: string; - version?: number; -}) => `${generateSchemaBaseUri(params)}v/${params.version ?? 1}` as const; +/** + * @todo use `extractBaseUri from the type system package when they're unified, + * and we're able to use functional code in node and web environments: + * https://app.asana.com/0/1200211978612931/1202923896339225/f + */ +export const extractBaseUri = (versionedUri: string) => { + const baseUri = versionedUri.split("v/")[0]; + if (baseUri == null) { + throw new Error( + `couldn't extract base URI, malformed Versioned URI: ${versionedUri}`, + ); + } + + return baseUri; +}; const primitiveDataTypeTitles = [ "Text", @@ -129,79 +122,134 @@ export type PrimitiveDataTypeTitle = typeof primitiveDataTypeTitles[number]; export const primitiveDataTypeVersionedUris = primitiveDataTypeTitles.reduce( (prev, title) => ({ ...prev, - [title]: generateSchemaVersionedUri({ - namespaceUri: blockprotocolTypesNamespaceUri, - kind: "dataType", + [title]: generateSchemaUri({ + domain: "https://blockprotocol.org", + namespace: "blockprotocol", + kind: "data-type", title, - /** @todo: get latest version of primitive data tyeps incase they are udpated */ - version: 1, }), }), {}, ) as Record; -/** - * Helper method for generating a property type schema for the Graph API. - * - * @todo make use of new type system package instead of ad-hoc types. - * https://app.asana.com/0/1202805690238892/1202892835843657/f - */ -export const generateWorkspacePropertyTypeSchema = (params: { +export type PropertyTypeCreatorParams = { + namespace: string; title: string; - possibleValues: { primitiveDataType?: PrimitiveDataTypeTitle; - propertyTypeObject?: { [_ in string]: { $ref: string } }; + propertyTypeObjectProperties?: { [_ in string]: { $ref: string } }; array?: boolean; }[]; -}): PropertyType => ({ - $id: generateSchemaVersionedUri({ - namespaceUri: workspaceTypesNamespaceUri, +}; + +/** + * Helper method for generating a property type schema for the Graph API. + */ +export const generateWorkspacePropertyTypeSchema = ( + params: PropertyTypeCreatorParams, +): PropertyType => { + const $id = generateSchemaUri({ + namespace: params.namespace, title: params.title, - kind: "propertyType", - }), - kind: "propertyType", - title: params.title, - pluralTitle: params.title, - oneOf: params.possibleValues.map( - ({ array, primitiveDataType, propertyTypeObject }) => { - let inner; + kind: "property-type", + }); + + const possibleValues = params.possibleValues.map( + ({ array, primitiveDataType, propertyTypeObjectProperties }) => { + let inner: PropertyValues; + if (primitiveDataType) { - inner = { + const dataTypeReference: PropertyValues.DataTypeReference = { $ref: primitiveDataTypeVersionedUris[primitiveDataType], }; - } else if (propertyTypeObject) { - inner = { type: "object" as const, properties: propertyTypeObject }; + inner = dataTypeReference; + } else if (propertyTypeObjectProperties) { + const propertyTypeObject: PropertyValues.PropertyTypeObject = { + type: "object" as const, + properties: propertyTypeObjectProperties, + }; + inner = propertyTypeObject; } else { throw new Error( - "Please provide either a primitiveDataType or a propertyTypeObject to generateWorkspacePropertyTypeSchema", + "Please provide either a primitiveDataType or propertyTypeObjectProperties to generateWorkspacePropertyTypeSchema", ); } + + // Optionally wrap inner in an array if (array) { - return { + const arrayOfPropertyValues: PropertyValues.ArrayOfPropertyValues = { type: "array", items: { oneOf: [inner], }, }; + return arrayOfPropertyValues; } else { return inner; } }, - /** - * @todo remove this cast when the method uses the new type system package. - * https://app.asana.com/0/1202805690238892/1202892835843657/f - */ - ) as any, -}); + ); + + return { + $id, + kind: "propertyType", + title: params.title, + pluralTitle: params.title, + oneOf: possibleValues, + }; +}; /** - * Helper method for generating an entity schema for the Graph API. + * Returns a function which can be used to initialize a given property type. This asynchronous design allows us to express + * dependencies between types in a lazy fashion, where the dependencies can be initialized as they're encountered. (This is + * likely to cause problems if we introduce circular dependencies) * - * @todo make use of new type system package instead of ad-hoc types. - * https://app.asana.com/0/1202805690238892/1202892835843657/f + * @param params the data required to create a new property type + * @returns an async function which can be called to initialize the property type, returning its PropertyTypeModel */ -export const generateWorkspaceEntityTypeSchema = (params: { +export const propertyTypeInitializer = ( + params: PropertyTypeCreatorParams, +): ((graphApi: GraphApi) => Promise) => { + let propertyTypeModel: PropertyTypeModel; + + return async (graphApi?: GraphApi) => { + if (propertyTypeModel) { + return propertyTypeModel; + } else if (graphApi == null) { + throw new Error( + `property type ${params.title} was uninitialized, and function was called without passing a graphApi object`, + ); + } else { + const propertyType = generateWorkspacePropertyTypeSchema(params); + + // initialize + propertyTypeModel = await PropertyTypeModel.get(graphApi, { + versionedUri: propertyType.$id, + }).catch(async (error: AxiosError) => { + if (error.response?.status === 404) { + // The type was missing, try and create it + return await PropertyTypeModel.create(graphApi, { + accountId: workspaceAccountId, + schema: propertyType, + }).catch((createError: AxiosError) => { + logger.warn(`Failed to create property type: ${params.title}`); + throw createError; + }); + } else { + logger.warn( + `Failed to check existence of property type: ${params.title}`, + ); + throw error; + } + }); + + return propertyTypeModel; + } + }; +}; + +export type EntityCreatorParams = { + namespace: string; title: string; properties: { baseUri: string; @@ -209,59 +257,107 @@ export const generateWorkspaceEntityTypeSchema = (params: { required?: boolean; array?: { minItems?: number; maxItems?: number } | boolean; }[]; -}): EntityType => ({ - $id: generateSchemaVersionedUri({ - namespaceUri: workspaceTypesNamespaceUri, +}; + +/** + * Helper method for generating an entity type schema for the Graph API. + * + * @todo make use of new type system package instead of ad-hoc types. + * https://app.asana.com/0/1202805690238892/1202892835843657/f + */ +export const generateWorkspaceEntityTypeSchema = ( + params: EntityCreatorParams, +): EntityType => { + const $id = generateSchemaUri({ + namespace: params.namespace, title: params.title, - kind: "entityType", - }), - title: params.title, - pluralTitle: params.title, - type: "object", - kind: "entityType", - properties: params.properties.reduce( - (prev, { baseUri, versionedUri, array }) => ({ - ...prev, - [baseUri]: array - ? { - type: "array", - items: { $ref: versionedUri }, - ...(array === true ? {} : array), - } - : { $ref: versionedUri }, - }), + kind: "entity-type", + }); + + /** @todo - clean this up to be more readable: https://app.asana.com/0/1202805690238892/1202931031833226/f */ + const properties = params.properties.reduce( + (prev, { versionedUri, array }) => { + /** + * @todo - use the Type System package to extract the base URI, this is currently blocked by unifying the packages + * so we can use the node/web version within API depending on env: + * https://app.asana.com/0/1200211978612931/1202923896339225/f + */ + const baseUri = versionedUri.split("v/")[0]!; + + return { + ...prev, + [baseUri]: array + ? { + type: "array", + items: { $ref: versionedUri }, + ...(array === true ? {} : array), + } + : { $ref: versionedUri }, + }; + }, {}, - ), - required: params.properties + ); + + const requiredProperties = params.properties .filter(({ required }) => !!required) - .map(({ baseUri }) => baseUri), -}); + .map(({ baseUri }) => baseUri); + + return { + $id, + title: params.title, + pluralTitle: params.title, + type: "object", + kind: "entityType", + properties, + required: requiredProperties, + }; +}; /** - * @todo make use of new type system package for managing URI structure. - * https://app.asana.com/0/1202805690238892/1202892835843657/f + * Returns a function which can be used to initialize a given entity type. This asynchronous design allows us to express + * dependencies between types in a lazy fashion, where the dependencies can be initialized as they're encountered. (This is + * likely to cause problems if we introduce circular dependencies) + * + * @param params the data required to create a new entity type + * @returns an async function which can be called to initialize the entity type, returning its EntityTypeModel */ -export const incrementVersionedId = (verisonedId: string): string => { - // Invariant: the last part of a versioned URI is /v/N where N is always a positive number - // with no trailing slash - const splitAt = "/v/"; +export const entityTypeInitializer = ( + params: EntityCreatorParams, +): ((graphApi: GraphApi) => Promise) => { + let entityTypeModel: EntityTypeModel; - // Given - // "http://example.com/et/v/1" - // find index * - const versionPosition = verisonedId.lastIndexOf(splitAt); + return async (graphApi?: GraphApi) => { + if (entityTypeModel) { + return entityTypeModel; + } else if (graphApi == null) { + throw new Error( + `entity type ${params.title} was uninitialized, and function was called without passing a graphApi object`, + ); + } else { + const entityType = generateWorkspaceEntityTypeSchema(params); - // Given invariant and index - // "http://example.com/et/v/1" - // * - // parse and add 1. - const newVersion = - parseInt(verisonedId.substring(versionPosition + splitAt.length), 10) + 1; + // initialize + entityTypeModel = await EntityTypeModel.get(graphApi, { + versionedUri: entityType.$id, + }).catch(async (error: AxiosError) => { + if (error.response?.status === 404) { + // The type was missing, try and create it + return await EntityTypeModel.create(graphApi, { + accountId: workspaceAccountId, + schema: entityType, + }).catch((createError: AxiosError) => { + logger.warn(`Failed to create entity type: ${params.title}`); + throw createError; + }); + } else { + logger.warn( + `Failed to check existence of entity type: ${params.title}`, + ); + throw error; + } + }); - // Reconstruct with base and new version - // "http://example.com/et/v/" + "2" - return `${verisonedId.substring( - 0, - versionPosition + splitAt.length, - )}${newVersion}`; + return entityTypeModel; + } + }; }; diff --git a/packages/hash/backend-utils/src/system.ts b/packages/hash/backend-utils/src/system.ts index 6e791a73eba..acc4d19397e 100644 --- a/packages/hash/backend-utils/src/system.ts +++ b/packages/hash/backend-utils/src/system.ts @@ -1,5 +1,6 @@ import { getRequiredEnv } from "./environment"; +/** @todo - This shouldn't be set via an env-var. We should be resolving this as necessary through the model classes */ export const WORKSPACE_ACCOUNT_SHORTNAME = getRequiredEnv( "WORKSPACE_ACCOUNT_SHORTNAME", ); diff --git a/packages/hash/frontend/src/components/hooks/blockProtocolFunctions/ontology/ontology-types-shim.ts b/packages/hash/frontend/src/components/hooks/blockProtocolFunctions/ontology/ontology-types-shim.ts index d91c44d8412..d84ce78a2e3 100644 --- a/packages/hash/frontend/src/components/hooks/blockProtocolFunctions/ontology/ontology-types-shim.ts +++ b/packages/hash/frontend/src/components/hooks/blockProtocolFunctions/ontology/ontology-types-shim.ts @@ -80,7 +80,7 @@ export type GetDataTypeMessageCallback = MessageCallback< export type PropertyTypeResponse = Response<"propertyType", PropertyType>; export type CreatePropertyTypeRequest = { - propertyType: PropertyType; + propertyType: Omit; }; export type CreatePropertyTypeMessageCallback = MessageCallback< CreatePropertyTypeRequest, @@ -107,7 +107,7 @@ export type GetPropertyTypeMessageCallback = MessageCallback< export type UpdatePropertyTypeRequest = { propertyTypeVersionedUri: string; - propertyType: PropertyType; + propertyType: Omit; }; export type UpdatePropertyTypeMessageCallback = MessageCallback< UpdatePropertyTypeRequest, @@ -121,7 +121,7 @@ export type UpdatePropertyTypeMessageCallback = MessageCallback< export type EntityTypeResponse = Response<"entityType", EntityType>; export type EntityTypeRequest = { - entityType: EntityType; + entityType: Omit; }; export type CreateEntityTypeMessageCallback = MessageCallback< EntityTypeRequest, @@ -148,7 +148,7 @@ export type GetEntityTypeMessageCallback = MessageCallback< export type UpdateEntityTypeRequest = { entityTypeVersionedUri: string; - entityType: EntityType; + entityType: Omit; }; export type UpdateEntityTypeMessageCallback = MessageCallback< UpdateEntityTypeRequest, @@ -162,7 +162,7 @@ export type UpdateEntityTypeMessageCallback = MessageCallback< export type LinkTypeResponse = Response<"linkType", LinkType>; export type CreateLinkTypeRequest = { - linkType: LinkType; + linkType: Omit; }; export type CreateLinkTypeMessageCallback = MessageCallback< CreateLinkTypeRequest, @@ -189,7 +189,7 @@ export type GetLinkTypeMessageCallback = MessageCallback< export type UpdateLinkTypeRequest = { linkTypeVersionedUri: string; - linkType: LinkType; + linkType: Omit; }; export type UpdateLinkTypeMessageCallback = MessageCallback< UpdateLinkTypeRequest, diff --git a/packages/hash/frontend/src/graphql/queries/ontology/entity-type.queries.ts b/packages/hash/frontend/src/graphql/queries/ontology/entity-type.queries.ts index a3e832f6764..0dc2ee37e85 100644 --- a/packages/hash/frontend/src/graphql/queries/ontology/entity-type.queries.ts +++ b/packages/hash/frontend/src/graphql/queries/ontology/entity-type.queries.ts @@ -21,7 +21,10 @@ export const getAllLatestEntityTypesQuery = gql` `; export const createEntityTypeMutation = gql` - mutation createEntityType($accountId: ID!, $entityType: EntityType!) { + mutation createEntityType( + $accountId: ID! + $entityType: EntityTypeWithoutId! + ) { createEntityType(accountId: $accountId, entityType: $entityType) { entityTypeVersionedUri accountId @@ -34,7 +37,7 @@ export const updateEntityTypeMutation = gql` mutation updateEntityType( $accountId: ID! $entityTypeVersionedUri: String! - $updatedEntityType: EntityType! + $updatedEntityType: EntityTypeWithoutId! ) { updateEntityType( accountId: $accountId diff --git a/packages/hash/frontend/src/graphql/queries/ontology/link-type.queries.ts b/packages/hash/frontend/src/graphql/queries/ontology/link-type.queries.ts index 2c3cf231da0..a6d73c7ed24 100644 --- a/packages/hash/frontend/src/graphql/queries/ontology/link-type.queries.ts +++ b/packages/hash/frontend/src/graphql/queries/ontology/link-type.queries.ts @@ -21,7 +21,7 @@ export const getAllLatestLinkTypesQuery = gql` `; export const createLinkTypeMutation = gql` - mutation createLinkType($accountId: ID!, $linkType: LinkType!) { + mutation createLinkType($accountId: ID!, $linkType: LinkTypeWithoutId!) { createLinkType(accountId: $accountId, linkType: $linkType) { linkTypeVersionedUri accountId @@ -34,7 +34,7 @@ export const updateLinkTypeMutation = gql` mutation updateLinkType( $accountId: ID! $linkTypeVersionedUri: String! - $updatedLinkType: LinkType! + $updatedLinkType: LinkTypeWithoutId! ) { updateLinkType( accountId: $accountId diff --git a/packages/hash/frontend/src/graphql/queries/ontology/property-type.queries.ts b/packages/hash/frontend/src/graphql/queries/ontology/property-type.queries.ts index a1563b3b541..b345b4816c0 100644 --- a/packages/hash/frontend/src/graphql/queries/ontology/property-type.queries.ts +++ b/packages/hash/frontend/src/graphql/queries/ontology/property-type.queries.ts @@ -21,7 +21,10 @@ export const getAllLatestPropertyTypesQuery = gql` `; export const createPropertyTypeMutation = gql` - mutation createPropertyType($accountId: ID!, $propertyType: PropertyType!) { + mutation createPropertyType( + $accountId: ID! + $propertyType: PropertyTypeWithoutId! + ) { createPropertyType(accountId: $accountId, propertyType: $propertyType) { propertyTypeVersionedUri accountId @@ -34,7 +37,7 @@ export const updatePropertyTypeMutation = gql` mutation updatePropertyType( $accountId: ID! $propertyTypeVersionedUri: String! - $updatedPropertyType: PropertyType! + $updatedPropertyType: PropertyTypeWithoutId! ) { updatePropertyType( accountId: $accountId diff --git a/packages/hash/frontend/src/pages/type-editor/index.page.tsx b/packages/hash/frontend/src/pages/type-editor/index.page.tsx index b65073a209d..1c9b1f3e2d2 100644 --- a/packages/hash/frontend/src/pages/type-editor/index.page.tsx +++ b/packages/hash/frontend/src/pages/type-editor/index.page.tsx @@ -1,8 +1,7 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/router"; import { Box, Container } from "@mui/material"; -import { TextField } from "@hashintel/hash-design-system"; -import init, { validateVersionedUri } from "@blockprotocol/type-system-web"; +import init from "@blockprotocol/type-system-web"; import { Button } from "../../shared/ui"; import { useUser } from "../../components/hooks/useUser"; @@ -21,9 +20,6 @@ import { */ const ExampleUsage = ({ accountId }: { accountId: string }) => { const [content, setContent] = useState(); - const [propertyUri, setPropertyUri] = useState( - "https://blockprotocol.org/@alice/types/property-type/new-name/v/1", - ); const functions = useBlockProtocolFunctionsWithOntology(accountId); @@ -46,32 +42,29 @@ const ExampleUsage = ({ accountId }: { accountId: string }) => { const createPropertyType = useCallback(() => { void (async () => { - if (validateVersionedUri(propertyUri).type === "Ok") { - await functions - .createPropertyType({ - data: { - propertyType: { - kind: "propertyType", - $id: propertyUri, - title: "Name", - pluralTitle: "Names", - oneOf: [ - { - $ref: "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", - }, - ], - }, + await functions + .createPropertyType({ + data: { + propertyType: { + kind: "propertyType", + title: "Name", + pluralTitle: "Names", + oneOf: [ + { + $ref: "https://blockprotocol.org/@blockprotocol/types/data-type/text/v/1", + }, + ], }, - }) - .then((result) => { - setContent(JSON.stringify(result.data ?? {}, null, 2)); - }) - .catch((error) => { - setContent(JSON.stringify(error ?? {}, null, 2)); - }); - } + }, + }) + .then((result) => { + setContent(JSON.stringify(result.data ?? {}, null, 2)); + }) + .catch((error) => { + setContent(JSON.stringify(error ?? {}, null, 2)); + }); })(); - }, [functions, propertyUri, setContent]); + }, [functions, setContent]); return ( @@ -104,14 +97,6 @@ const ExampleUsage = ({ accountId }: { accountId: string }) => {

- setPropertyUri(event.target.value)} - focused - />