From 8c98d46198f830768cee83bb7e0108faa5aeb90d Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:36:58 +0200 Subject: [PATCH 1/8] feat: remove read module chore: add new dependency chore: format feat: error module feat: introduce hashql_eval interner chore: checkpoint feat: checkpoint feat: checkpoint chore: remove old value module feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint feat: checkpoint chore: checkpoint feat: move entity query into its own modul fix: query request feat: checkpoint (it compiles!) feat: checkpoint feat: checkpoint feat: checkpoint fix: issue around cached thunking feat: covariance for opaque inners fix: cfgattr serde chore: remove graph module fix: merge fuckup --- libs/@local/graph/api/src/rest/entity/mod.rs | 537 +++++++++++++ .../graph/api/src/rest/entity/query/filter.rs | 1 + .../graph/api/src/rest/entity/query/mod.rs | 260 +++++++ .../api/src/rest/entity/query/request.rs | 712 ++++++++++++++++++ .../manual_queries/entity_queries/mod.rs | 54 +- 5 files changed, 1530 insertions(+), 34 deletions(-) create mode 100644 libs/@local/graph/api/src/rest/entity/mod.rs create mode 100644 libs/@local/graph/api/src/rest/entity/query/filter.rs create mode 100644 libs/@local/graph/api/src/rest/entity/query/mod.rs create mode 100644 libs/@local/graph/api/src/rest/entity/query/request.rs diff --git a/libs/@local/graph/api/src/rest/entity/mod.rs b/libs/@local/graph/api/src/rest/entity/mod.rs new file mode 100644 index 00000000000..17e12fb342e --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/mod.rs @@ -0,0 +1,537 @@ +//! Web routes for CRU operations on entities. + +pub mod query; + +use alloc::sync::Arc; +use std::collections::HashMap; + +use axum::{Extension, Router, routing::post}; +use error_stack::{Report, ResultExt as _}; +use hash_graph_authorization::policies::principal::actor::AuthenticatedActor; +use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionOnUpdate}; +use hash_graph_store::{ + self, + entity::{ + ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, DiffEntityParams, + DiffEntityResult, EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, + EntityQuerySortingToken, EntityQueryToken, EntityStore, EntityTypesError, + EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, + LinkDataStateError, LinkDataValidationReport, LinkError, LinkTargetError, + LinkValidationReport, LinkedEntityError, MetadataValidationReport, PatchEntityParams, + PropertyMetadataValidationReport, QueryConversion, QueryEntitiesResponse, + UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, + ValidateEntityParams, + }, + pool::StorePool, + query::{NullOrdering, Ordering}, +}; +use hash_graph_types::{ + Embedding, + knowledge::{ + entity::EntityEmbedding, + property::visitor::{ + ArrayItemNumberMismatch, ArrayValidationReport, DataTypeCanonicalCalculation, + DataTypeConversionError, DataTypeInferenceError, JsonSchemaValueTypeMismatch, + ObjectPropertyValidationReport, ObjectValidationReport, OneOfArrayValidationReports, + OneOfObjectValidationReports, OneOfPropertyValidationReports, + PropertyArrayValidationReport, PropertyObjectValidationReport, + PropertyValidationReport, PropertyValueTypeMismatch, PropertyValueValidationReport, + ValueValidationError, ValueValidationReport, + }, + }, +}; +use hash_temporal_client::TemporalClient; +use serde::Deserialize as _; +use type_system::{ + knowledge::{ + Confidence, Entity, Property, + entity::{ + EntityMetadata, LinkData, + id::{EntityEditionId, EntityId, EntityRecordId, EntityUuid}, + metadata::{EntityTemporalMetadata, EntityTypeIdDiff}, + provenance::{ + EntityDeletionProvenance, EntityEditionProvenance, EntityProvenance, + InferredEntityProvenance, ProvidedEntityEditionProvenance, + }, + }, + property::{ + PropertyArrayWithMetadata, PropertyDiff, PropertyObject, PropertyObjectWithMetadata, + PropertyPatchOperation, PropertyPath, PropertyPathElement, PropertyValueWithMetadata, + PropertyWithMetadata, + metadata::{ + ArrayMetadata, ObjectMetadata, PropertyArrayMetadata, PropertyMetadata, + PropertyObjectMetadata, PropertyProvenance, PropertyValueMetadata, + }, + }, + value::{ValueMetadata, metadata::ValueProvenance}, + }, + principal::actor::ActorType, + provenance::{Location, OriginProvenance, SourceProvenance, SourceType}, +}; + +use self::query::{ + QueryEntitySubgraphResponse, count_entities, query_entities, query_entity_subgraph, + request::{QueryEntitiesRequest, QueryEntitySubgraphRequest}, +}; +use crate::rest::{ + AuthenticatedUserHeader, OpenApiQuery, QueryLogger, + json::Json, + status::{BoxedResponse, report_to_response}, +}; + +#[derive(utoipa::OpenApi)] +#[openapi( + paths( + create_entity, + create_entities, + validate_entity, + has_permission_for_entities, + self::query::query_entities, + self::query::query_entity_subgraph, + self::query::count_entities, + patch_entity, + update_entity_embeddings, + diff_entity, + ), + components( + schemas( + CreateEntityParams, + PropertyWithMetadata, + PropertyValueWithMetadata, + PropertyArrayWithMetadata, + PropertyObjectWithMetadata, + ValidateEntityParams, + CountEntitiesParams, + EntityValidationType, + ValidateEntityComponents, + Embedding, + UpdateEntityEmbeddingsParams, + EntityEmbedding, + EntityQueryToken, + + PatchEntityParams, + PropertyPatchOperation, + + HasPermissionForEntitiesParams, + + QueryEntitiesRequest, + QueryEntitySubgraphRequest, + EntityQueryCursor, + Ordering, + NullOrdering, + EntityQuerySortingRecord, + EntityQuerySortingToken, + QueryEntitiesResponse, + QueryEntitySubgraphResponse, + EntityPermissions, + ClosedMultiEntityTypeMap, + QueryConversion, + + Entity, + Property, + PropertyProvenance, + PropertyObject, + ArrayMetadata, + ObjectMetadata, + ValueMetadata, + ValueProvenance, + PropertyObjectMetadata, + PropertyArrayMetadata, + PropertyValueMetadata, + PropertyMetadata, + EntityUuid, + EntityId, + EntityEditionId, + EntityMetadata, + EntityProvenance, + EntityDeletionProvenance, + EntityEditionProvenance, + InferredEntityProvenance, + ProvidedEntityEditionProvenance, + ActorType, + OriginProvenance, + SourceType, + SourceProvenance, + Location, + EntityRecordId, + EntityTemporalMetadata, + EntityQueryToken, + LinkData, + EntityValidationReport, + LinkedEntityError, + LinkDataValidationReport, + LinkDataStateError, + LinkValidationReport, + LinkError, + LinkTargetError, + UnexpectedEntityType, + MetadataValidationReport, + EntityTypesError, + PropertyMetadataValidationReport, + ObjectPropertyValidationReport, + JsonSchemaValueTypeMismatch, + ArrayValidationReport, + ArrayItemNumberMismatch, + PropertyValidationReport, + OneOfPropertyValidationReports, + PropertyValueValidationReport, + ObjectValidationReport, + DataTypeConversionError, + DataTypeCanonicalCalculation, + DataTypeInferenceError, + PropertyValueTypeMismatch, + OneOfArrayValidationReports, + PropertyArrayValidationReport, + OneOfObjectValidationReports, + PropertyObjectValidationReport, + ValueValidationReport, + ValueValidationError, + + DiffEntityParams, + DiffEntityResult, + EntityTypeIdDiff, + PropertyDiff, + PropertyPath, + PropertyPathElement, + Confidence, + ) + ), + tags( + (name = "Entity", description = "entity management API") + ) +)] +pub(crate) struct EntityResource; + +impl EntityResource { + /// Create routes for interacting with entities. + pub(crate) fn routes() -> Router + where + S: StorePool + Send + Sync + 'static, + { + // TODO: The URL format here is preliminary and will have to change. + Router::new().nest( + "/entities", + Router::new() + .route("/", post(create_entity::).patch(patch_entity::)) + .route("/bulk", post(create_entities::)) + .route("/diff", post(diff_entity::)) + .route("/validate", post(validate_entity::)) + .route("/embeddings", post(update_entity_embeddings::)) + .route("/permissions", post(has_permission_for_entities::)) + .nest( + "/query", + Router::new() + .route("/", post(query_entities::)) + .route("/subgraph", post(query_entity_subgraph::)) + .route("/count", post(count_entities::)), + ), + ) + } +} + +#[utoipa::path( + post, + path = "/entities", + request_body = CreateEntityParams, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "The created entity", body = Entity), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + + (status = 404, description = "Entity Type URL was not found"), + (status = 500, description = "Store error occurred"), + ), +)] +async fn create_entity( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Json(body): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + let params = CreateEntityParams::deserialize(&body) + .map_err(Report::from) + .map_err(report_to_response)?; + + let mut store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + store + .create_entity(actor_id, params) + .await + .map_err(report_to_response) + .map(Json) +} + +#[utoipa::path( + post, + path = "/entities/bulk", + request_body = [CreateEntityParams], + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "The created entities", body = [Entity]), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + + (status = 404, description = "Entity Type URL was not found"), + (status = 500, description = "Store error occurred"), + ), +)] +async fn create_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Json(body): Json, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + let params = Vec::::deserialize(&body) + .map_err(Report::from) + .map_err(report_to_response)?; + + let mut store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + store + .create_entities(actor_id, params) + .await + .map_err(report_to_response) + .map(Json) +} + +#[utoipa::path( + post, + path = "/entities/validate", + request_body = ValidateEntityParams, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "The validation report", body = HashMap), + (status = 400, content_type = "application/json", description = "The entity validation failed"), + + (status = 404, description = "Entity Type URL was not found"), + (status = 500, description = "Store error occurred"), + ), +)] +async fn validate_entity( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + mut query_logger: Option>, + Json(body): Json, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::ValidateEntity(&body)); + } + + let params = ValidateEntityParams::deserialize(&body) + .map_err(Report::from) + .map_err(report_to_response)?; + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let response = store + .validate_entity(actor_id, params) + .await + .map_err(report_to_response) + .map(Json); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} + +#[utoipa::path( + post, + path = "/entities/permissions", + tag = "Entity", + request_body = HasPermissionForEntitiesParams, + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, body = HashMap>, description = "Information if the actor has the permission for the entities"), + + (status = 500, description = "Internal error occurred"), + ) +)] +async fn has_permission_for_entities( + AuthenticatedUserHeader(actor): AuthenticatedUserHeader, + temporal_client: Extension>>, + store_pool: Extension>, + Json(params): Json>, +) -> Result>>, BoxedResponse> +where + S: StorePool + Send + Sync, + for<'p> S::Store<'p>: EntityStore, +{ + store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)? + .has_permission_for_entities(AuthenticatedActor::from(actor), params) + .await + .map(Json) + .map_err(report_to_response) +} + +#[utoipa::path( + patch, + path = "/entities", + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "The updated entity", body = Entity), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + (status = 423, content_type = "text/plain", description = "The entity that should be updated was unexpectedly updated at the same time"), + + (status = 404, description = "Entity ID or Entity Type URL was not found"), + (status = 500, description = "Store error occurred"), + ), + request_body = PatchEntityParams, +)] +async fn patch_entity( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Json(params): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + let mut store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + store + .patch_entity(actor_id, params) + .await + .map_err(|report| { + if report.contains::() { + report.attach_opaque(hash_status::StatusCode::NotFound) + } else if report.contains::() { + report.attach_opaque(hash_status::StatusCode::Cancelled) + } else { + report + } + }) + .map_err(report_to_response) + .map(Json) +} + +#[utoipa::path( + post, + path = "/entities/embeddings", + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 204, content_type = "application/json", description = "The embeddings were created"), + + (status = 403, description = "Insufficient permissions to update the entity"), + (status = 500, description = "Store error occurred"), + ), + request_body = UpdateEntityEmbeddingsParams, +)] +async fn update_entity_embeddings( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Json(body): Json, +) -> Result<(), BoxedResponse> +where + S: StorePool + Send + Sync, +{ + // Manually deserialize the request from a JSON value to allow borrowed deserialization and + // better error reporting. + let params = UpdateEntityEmbeddingsParams::deserialize(body) + .attach_opaque(hash_status::StatusCode::InvalidArgument) + .map_err(report_to_response)?; + + let mut store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + store + .update_entity_embeddings(actor_id, params) + .await + .map_err(report_to_response) +} + +#[utoipa::path( + post, + path = "/entities/diff", + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ), + responses( + (status = 200, content_type = "application/json", description = "The difference between the two entities", body = DiffEntityResult), + (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), + + (status = 404, description = "Entity ID was not found"), + (status = 500, description = "Store error occurred"), + ), + request_body = DiffEntityParams, +)] +async fn diff_entity( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + mut query_logger: Option>, + Json(params): Json, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::DiffEntity(¶ms)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let response = store + .diff_entity(actor_id, params) + .await + .map_err(|report| { + if report.contains::() { + report.attach_opaque(hash_status::StatusCode::NotFound) + } else { + report + } + }) + .map_err(report_to_response) + .map(Json); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} diff --git a/libs/@local/graph/api/src/rest/entity/query/filter.rs b/libs/@local/graph/api/src/rest/entity/query/filter.rs new file mode 100644 index 00000000000..8b137891791 --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/query/filter.rs @@ -0,0 +1 @@ + diff --git a/libs/@local/graph/api/src/rest/entity/query/mod.rs b/libs/@local/graph/api/src/rest/entity/query/mod.rs new file mode 100644 index 00000000000..12c30fdb443 --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/query/mod.rs @@ -0,0 +1,260 @@ +pub(crate) mod filter; +pub(crate) mod request; + +use alloc::sync::Arc; +use std::collections::HashMap; + +use axum::Extension; +use error_stack::{Report, ResultExt as _}; +use hash_graph_store::{ + entity::{ + ClosedMultiEntityTypeMap, CountEntitiesParams, EntityPermissions, EntityQueryCursor, + EntityStore as _, QueryEntitiesResponse, + }, + entity_type::EntityTypeResolveDefinitions, + pool::StorePool, +}; +use hash_temporal_client::TemporalClient; +use serde::Deserialize as _; +use serde_json::value::RawValue as RawJsonvalue; +use type_system::{ + knowledge::entity::id::EntityId, + ontology::VersionedUrl, + principal::{actor::ActorEntityUuid, actor_group::WebId}, +}; + +pub use self::request::{ + QueryEntitiesRequest, QueryEntitySubgraphError, QueryEntitySubgraphRequest, +}; +use crate::rest::{ + ApiConfig, AuthenticatedUserHeader, OpenApiQuery, QueryLogger, + json::Json, + status::{BoxedResponse, report_to_response}, + utoipa_typedef::subgraph::Subgraph, +}; + +#[utoipa::path( + post, + path = "/entities/query", + request_body = QueryEntitiesRequest, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ("after" = Option, Query, description = "The cursor to start reading from"), + ("limit" = Option, Query, description = "The maximum number of entities to read"), + ), + responses( + ( + status = 200, + content_type = "application/json", + body = QueryEntitiesResponse, + description = "A list of entities that satisfy the given query.", + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn query_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Extension(api_config): Extension, + mut query_logger: Option>, + Json(request): Json>, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::GetEntities(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let request = QueryEntitiesRequest::deserialize(&*request) + .map_err(Report::from) + .map_err(report_to_response)?; + + let params = request + .into_params(api_config) + .attach(hash_status::StatusCode::InvalidArgument) + .map_err(report_to_response)?; + + let response = store + .query_entities(actor_id, params) + .await + .map(Json) + .map_err(report_to_response); + + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub(super) struct QueryEntitySubgraphResponse<'r> { + subgraph: Subgraph, + #[serde(borrow)] + cursor: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + closed_multi_entity_types: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + definitions: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + web_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + created_by_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + edition_created_by_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + type_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + type_titles: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(nullable = false)] + entity_permissions: Option>, +} + +#[utoipa::path( + post, + path = "/entities/query/subgraph", + request_body = QueryEntitySubgraphRequest, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + ("after" = Option, Query, description = "The cursor to start reading from"), + ("limit" = Option, Query, description = "The maximum number of entities to read"), + ), + responses( + ( + status = 200, + content_type = "application/json", + body = QueryEntitySubgraphResponse, + description = "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn query_entity_subgraph( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + Extension(api_config): Extension, + mut query_logger: Option>, + Json(request): Json, +) -> Result>, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::GetEntitySubgraph(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let request = QueryEntitySubgraphRequest::deserialize(&request) + .map_err(Report::from) + .map_err(report_to_response)?; + + let params = request + .into_traversal_params(api_config) + .attach(hash_status::StatusCode::InvalidArgument) + .map_err(report_to_response)?; + + let response = store + .query_entity_subgraph(actor_id, params) + .await + .map(|response| { + Json(QueryEntitySubgraphResponse { + subgraph: response.subgraph.into(), + cursor: response.cursor.map(EntityQueryCursor::into_owned), + count: response.count, + closed_multi_entity_types: response.closed_multi_entity_types, + definitions: response.definitions, + web_ids: response.web_ids, + created_by_ids: response.created_by_ids, + edition_created_by_ids: response.edition_created_by_ids, + type_ids: response.type_ids, + type_titles: response.type_titles, + entity_permissions: response.entity_permissions, + }) + }) + .map_err(report_to_response); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} + +#[utoipa::path( + post, + path = "/entities/query/count", + request_body = CountEntitiesParams, + tag = "Entity", + params( + ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), + + ), + responses( + ( + status = 200, + content_type = "application/json", + body = usize, + ), + (status = 422, content_type = "text/plain", description = "Provided query is invalid"), + (status = 500, description = "Store error occurred"), + ) +)] +pub(super) async fn count_entities( + AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, + store_pool: Extension>, + temporal_client: Extension>>, + mut query_logger: Option>, + Json(request): Json, +) -> Result, BoxedResponse> +where + S: StorePool + Send + Sync, +{ + if let Some(query_logger) = &mut query_logger { + query_logger.capture(actor_id, OpenApiQuery::CountEntities(&request)); + } + + let store = store_pool + .acquire(temporal_client.0) + .await + .map_err(report_to_response)?; + + let response = store + .count_entities( + actor_id, + CountEntitiesParams::deserialize(&request) + .map_err(Report::from) + .map_err(report_to_response)?, + ) + .await + .map(Json) + .map_err(report_to_response); + if let Some(query_logger) = &mut query_logger { + query_logger.send().await.map_err(report_to_response)?; + } + response +} diff --git a/libs/@local/graph/api/src/rest/entity/query/request.rs b/libs/@local/graph/api/src/rest/entity/query/request.rs new file mode 100644 index 00000000000..4bf9f4292f9 --- /dev/null +++ b/libs/@local/graph/api/src/rest/entity/query/request.rs @@ -0,0 +1,712 @@ +use error_stack::{Report, ResultExt as _}; +use hash_graph_store::{ + entity::{ + EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, + QueryConversion, QueryEntitiesParams, QueryEntitySubgraphParams, + }, + entity_type::IncludeEntityTypeOption, + filter::Filter, + query::Ordering, + subgraph::{ + edges::{ + EntityTraversalPath, GraphResolveDepths, MAX_TRAVERSAL_PATHS, SubgraphTraversalParams, + TraversalDepthError, TraversalPath, + }, + temporal_axes::QueryTemporalAxesUnresolved, + }, +}; +use type_system::knowledge::Entity; + +use crate::rest::{ApiConfig, LimitExceededError, resolve_limit}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display)] +pub enum QueryEntitySubgraphError { + #[display("Query limit exceeded")] + Limit, + #[display("Traversal depth exceeded")] + TraversalDepth, + #[display("Resolve depth exceeded")] + ResolveDepth, +} + +impl core::error::Error for QueryEntitySubgraphError {} + +fn validate_traversal( + params: &SubgraphTraversalParams, +) -> Result<(), Report> { + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + if traversal_paths.len() > MAX_TRAVERSAL_PATHS { + return Err(Report::new(TraversalDepthError::TooManyPaths { + actual: traversal_paths.len(), + max: MAX_TRAVERSAL_PATHS, + }) + .change_context(QueryEntitySubgraphError::TraversalDepth)); + } + for path in traversal_paths { + path.validate() + .change_context(QueryEntitySubgraphError::TraversalDepth)?; + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => { + if traversal_paths.len() > MAX_TRAVERSAL_PATHS { + return Err(Report::new(TraversalDepthError::TooManyPaths { + actual: traversal_paths.len(), + max: MAX_TRAVERSAL_PATHS, + }) + .change_context(QueryEntitySubgraphError::TraversalDepth)); + } + for path in traversal_paths { + path.validate() + .change_context(QueryEntitySubgraphError::TraversalDepth)?; + } + graph_resolve_depths + .validate() + .change_context(QueryEntitySubgraphError::ResolveDepth)?; + } + } + Ok(()) +} + +#[tracing::instrument(level = "info", skip_all)] +fn generate_sorting_paths( + paths: Option>>, + temporal_axes: &QueryTemporalAxesUnresolved, +) -> Vec> { + let temporal_axes_sorting_path = match temporal_axes { + QueryTemporalAxesUnresolved::TransactionTime { .. } => &EntityQueryPath::TransactionTime, + QueryTemporalAxesUnresolved::DecisionTime { .. } => &EntityQueryPath::DecisionTime, + }; + + paths + .map_or_else( + || { + vec![ + EntityQuerySortingRecord { + path: temporal_axes_sorting_path.clone(), + ordering: Ordering::Descending, + nulls: None, + }, + EntityQuerySortingRecord { + path: EntityQueryPath::Uuid, + ordering: Ordering::Ascending, + nulls: None, + }, + EntityQuerySortingRecord { + path: EntityQueryPath::WebId, + ordering: Ordering::Ascending, + nulls: None, + }, + ] + }, + |mut paths| { + let mut has_temporal_axis = false; + let mut has_uuid = false; + let mut has_web_id = false; + + for path in &paths { + if path.path == EntityQueryPath::TransactionTime + || path.path == EntityQueryPath::DecisionTime + { + has_temporal_axis = true; + } + if path.path == EntityQueryPath::Uuid { + has_uuid = true; + } + if path.path == EntityQueryPath::WebId { + has_web_id = true; + } + } + + if !has_temporal_axis { + paths.push(EntityQuerySortingRecord { + path: temporal_axes_sorting_path.clone(), + ordering: Ordering::Descending, + nulls: None, + }); + } + if !has_uuid { + paths.push(EntityQuerySortingRecord { + path: EntityQueryPath::Uuid, + ordering: Ordering::Ascending, + nulls: None, + }); + } + if !has_web_id { + paths.push(EntityQuerySortingRecord { + path: EntityQueryPath::WebId, + ordering: Ordering::Ascending, + nulls: None, + }); + } + + paths + }, + ) + .into_iter() + .map(EntityQuerySortingRecord::into_owned) + .collect() +} + +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +#[expect( + clippy::struct_excessive_bools, + reason = "Parameter struct deserialized from JSON" +)] +#[serde(rename_all = "camelCase")] +pub struct QueryEntitiesRequest<'q, 's, 'p> { + #[serde(borrow)] + pub filter: Filter<'q, Entity>, + + pub temporal_axes: QueryTemporalAxesUnresolved, + pub include_drafts: bool, + pub limit: Option, + #[serde(borrow, default)] + pub conversions: Vec>, + #[serde(borrow)] + pub sorting_paths: Option>>, + #[serde(borrow)] + pub cursor: Option>, + #[serde(default)] + pub include_count: bool, + #[serde(default)] + pub include_entity_types: Option, + #[serde(default)] + pub include_web_ids: bool, + #[serde(default)] + pub include_created_by_ids: bool, + #[serde(default)] + pub include_edition_created_by_ids: bool, + #[serde(default)] + pub include_type_ids: bool, + #[serde(default)] + pub include_type_titles: bool, + pub include_permissions: bool, +} + +impl<'q, 'p> QueryEntitiesRequest<'q, '_, 'p> { + /// # Errors + /// + /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in + /// [`ApiConfig::query_entity_limit`]. + pub fn into_params( + self, + config: ApiConfig, + ) -> Result, Report> + where + 'p: 'q, + { + let limit = resolve_limit(self.limit, config.query_entity_limit)?; + + Ok(QueryEntitiesParams { + filter: self.filter, + sorting: EntityQuerySorting { + paths: generate_sorting_paths(self.sorting_paths, &self.temporal_axes), + cursor: self.cursor.map(EntityQueryCursor::into_owned), + }, + limit, + conversions: self.conversions, + include_drafts: self.include_drafts, + include_count: self.include_count, + include_entity_types: self.include_entity_types, + temporal_axes: self.temporal_axes, + include_web_ids: self.include_web_ids, + include_created_by_ids: self.include_created_by_ids, + include_edition_created_by_ids: self.include_edition_created_by_ids, + include_type_ids: self.include_type_ids, + include_type_titles: self.include_type_titles, + include_permissions: self.include_permissions, + }) + } +} + +#[derive(Debug, Clone, serde::Deserialize, utoipa::ToSchema)] +#[serde(untagged, deny_unknown_fields)] +pub enum QueryEntitySubgraphRequest<'q, 's, 'p> { + #[serde(rename_all = "camelCase")] + ResolveDepths { + traversal_paths: Vec, + graph_resolve_depths: GraphResolveDepths, + #[serde(borrow, flatten)] + request: QueryEntitiesRequest<'q, 's, 'p>, + }, + #[serde(rename_all = "camelCase")] + Paths { + traversal_paths: Vec, + #[serde(borrow, flatten)] + request: QueryEntitiesRequest<'q, 's, 'p>, + }, +} + +impl<'q, 's, 'p> QueryEntitySubgraphRequest<'q, 's, 'p> { + #[must_use] + pub fn into_parts(self) -> (QueryEntitiesRequest<'q, 's, 'p>, SubgraphTraversalParams) { + match self { + QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: options, + } => (options, SubgraphTraversalParams::Paths { traversal_paths }), + QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request: options, + } => ( + options, + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + }, + ), + } + } + + #[must_use] + pub fn from_parts( + request: QueryEntitiesRequest<'q, 's, 'p>, + params: SubgraphTraversalParams, + ) -> Self { + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + QueryEntitySubgraphRequest::Paths { + traversal_paths, + request, + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }, + } + } + + /// # Errors + /// + /// Returns [`QueryEntitySubgraphError`] if: + /// - The requested limit exceeds the configured maximum. + /// - The number of traversal paths exceeds [`MAX_TRAVERSAL_PATHS`]. + /// - Any traversal path exceeds the maximum edge count. + /// - Graph resolve depths exceed the allowed maximum. + pub fn into_traversal_params( + self, + config: ApiConfig, + ) -> Result, Report> + where + 'p: 'q, + { + let (request, params) = self.into_parts(); + + validate_traversal(¶ms)?; + + let request = request + .into_params(config) + .change_context(QueryEntitySubgraphError::Limit)?; + + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + Ok(QueryEntitySubgraphParams::Paths { + traversal_paths, + request, + }) + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => Ok(QueryEntitySubgraphParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }), + } + } +} + +#[cfg(test)] +mod tests { + use core::assert_matches; + + use serde_json::json; + + use super::*; + + /// Minimal valid temporal axes for test payloads. + fn temporal_axes() -> serde_json::Value { + json!({ + "pinned": { + "axis": "transactionTime", + "timestamp": null + }, + "variable": { + "axis": "decisionTime", + "interval": { + "start": null, + "end": null + } + } + }) + } + + /// Minimal valid request body shared across tests. + fn base_request() -> String { + json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string() + } + + #[test] + fn deserialize_minimal_entity_request() { + let payload = base_request(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + include_drafts: false, + include_permissions: false, + limit: None, + sorting_paths: None, + cursor: None, + include_count: false, + include_entity_types: None, + include_web_ids: false, + include_created_by_ids: false, + include_edition_created_by_ids: false, + include_type_ids: false, + include_type_titles: false, + .. + }) + ); + } + + #[test] + fn deserialize_entity_request_with_all_fields() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": true, + "includePermissions": true, + "limit": 50, + "includeCount": true, + "includeWebIds": true, + "includeCreatedByIds": true, + "includeEditionCreatedByIds": true, + "includeTypeIds": true, + "includeTypeTitles": true + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + include_drafts: true, + include_permissions: true, + limit: Some(50), + include_count: true, + include_web_ids: true, + include_created_by_ids: true, + include_edition_created_by_ids: true, + include_type_ids: true, + include_type_titles: true, + .. + }) + ); + } + + #[test] + fn reject_entity_request_missing_filter() { + let payload = json!({ + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing filter should fail") + .to_string(); + assert!(err.starts_with("missing field `filter`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_temporal_axes() { + let payload = json!({ + "filter": { "all": [] }, + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing temporalAxes should fail") + .to_string(); + assert!(err.starts_with("missing field `temporalAxes`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_include_drafts() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing includeDrafts should fail") + .to_string(); + assert!(err.starts_with("missing field `includeDrafts`"), "{err}"); + } + + #[test] + fn reject_entity_request_missing_include_permissions() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing includePermissions should fail") + .to_string(); + assert!( + err.starts_with("missing field `includePermissions`"), + "{err}" + ); + } + + #[test] + fn deserialize_subgraph_paths_variant() { + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: QueryEntitiesRequest { include_drafts: false, .. }, + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } + + #[test] + fn deserialize_subgraph_resolve_depths_variant() { + let payload = json!({ + "traversalPaths": [], + "graphResolveDepths": { + "inheritsFrom": 0, + "constrainsValuesOn": 0, + "constrainsPropertiesOn": 0, + "constrainsLinksOn": 0, + "constrainsLinkDestinationsOn": 0, + "isOfType": false + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths: GraphResolveDepths { + inherits_from: 0, + is_of_type: false, + .. + }, + request: QueryEntitiesRequest { include_drafts: false, .. }, + }) if traversal_paths.is_empty() + ); + } + + #[test] + fn reject_subgraph_missing_traversal_paths() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("missing traversalPaths should fail") + .to_string(); + assert!( + err.starts_with( + "data did not match any variant of untagged enum QueryEntitySubgraphRequest" + ), + "{err}" + ); + } + + #[test] + fn deserialize_filter_request_with_limit_and_count() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "limit": 100, + "includeCount": true, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitiesRequest { + limit: Some(100), + include_count: true, + .. + }) + ); + } + + #[test] + fn deserialize_subgraph_resolve_depths_with_traversal() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "graphResolveDepths": { + "inheritsFrom": 255, + "constrainsValuesOn": 255, + "constrainsPropertiesOn": 255, + "constrainsLinksOn": 255, + "constrainsLinkDestinationsOn": 255, + "isOfType": true + }, + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::ResolveDepths { + traversal_paths, + graph_resolve_depths: GraphResolveDepths { + inherits_from: 255, + is_of_type: true, + .. + }, + request: QueryEntitiesRequest { include_permissions: false, .. }, + }) if traversal_paths.len() == 1 + ); + } + + #[test] + fn reject_resolve_depths_with_non_entity_edge() { + // If traversalPaths contains an ontology edge (e.g. "is-of-type"), it can't + // deserialize as EntityTraversalPath. The untagged enum must not silently + // fall through to the Paths variant, dropping graphResolveDepths. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "is-of-type" } + ] + } + ], + "graphResolveDepths": { + "inheritsFrom": 255, + "constrainsValuesOn": 255, + "constrainsPropertiesOn": 255, + "constrainsLinksOn": 255, + "constrainsLinkDestinationsOn": 255, + "isOfType": true + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + let result = serde_json::from_str::>(&payload); + + match result { + Err(_) => {} // Correctly rejected + Ok(QueryEntitySubgraphRequest::ResolveDepths { .. }) => { + panic!("should not parse ontology edges as EntityTraversalPath"); + } + Ok(QueryEntitySubgraphRequest::Paths { .. }) => { + panic!("silently fell through to Paths variant, dropping graphResolveDepths"); + } + } + } + + #[test] + fn deserialize_paths_with_ontology_edge() { + // Ontology edges (like is-of-type) are valid in TraversalPath but not + // EntityTraversalPath. Without graphResolveDepths, this should parse as Paths. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "is-of-type" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + .. + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } + + #[test] + fn deserialize_subgraph_paths_with_traversal() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" }, + { "kind": "has-right-entity", "direction": "outgoing" } + ] + } + ], + "includeDrafts": false, + "includePermissions": false + }) + .to_string(); + assert_matches!( + serde_json::from_str::>(&payload), + Ok(QueryEntitySubgraphRequest::Paths { + traversal_paths, + request: QueryEntitiesRequest { include_permissions: false, .. }, + }) if traversal_paths.len() == 1 && traversal_paths[0].edges.len() == 2 + ); + } +} diff --git a/tests/graph/benches/manual_queries/entity_queries/mod.rs b/tests/graph/benches/manual_queries/entity_queries/mod.rs index ff0e4d76963..092d4cd9ad5 100644 --- a/tests/graph/benches/manual_queries/entity_queries/mod.rs +++ b/tests/graph/benches/manual_queries/entity_queries/mod.rs @@ -6,8 +6,8 @@ use criterion_macro::criterion; use either::Either; use error_stack::Report; use hash_graph_api::rest::{ - self, ApiConfig, - entity::{EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest}, + ApiConfig, + entity::query::{QueryEntitiesRequest, QueryEntitySubgraphRequest}, }; use hash_graph_postgres_store::{ Environment, load_env, @@ -142,13 +142,11 @@ impl QueryEntitiesQuery<'_, '_, '_> { let modifies_limit = !self.settings.parameters.limit.is_empty(); let modifies_include_count = !self.settings.parameters.include_count.is_empty(); - let (query, options) = self.request.into_parts(); - let actor_id = iter::once(self.actor_id) .chain(mem::take(&mut self.settings.parameters.actor_id)) .sorted_by_key(|actor_id| Uuid::from(*actor_id)) .dedup(); - let limit = iter::once(options.limit) + let limit = iter::once(self.request.limit) .chain( mem::take(&mut self.settings.parameters.limit) .into_iter() @@ -156,7 +154,7 @@ impl QueryEntitiesQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) + let include_count = iter::once(self.request.include_count) .chain(mem::take(&mut self.settings.parameters.include_count)) .sorted() .dedup(); @@ -175,14 +173,11 @@ impl QueryEntitiesQuery<'_, '_, '_> { ( Self { actor_id, - request: QueryEntitiesRequest::from_parts( - query.clone(), - EntityQueryOptions { - limit, - include_count, - ..options.clone() - }, - ), + request: QueryEntitiesRequest { + limit, + include_count, + ..self.request.clone() + }, settings: self.settings.clone(), }, parameters.join(","), @@ -252,13 +247,13 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { let modifies_include_count = !self.settings.parameters.include_count.is_empty(); let modifies_graph_resolve_depths = !self.settings.parameters.traversal_params.is_empty(); - let (query, options, traversal_params) = self.request.clone().into_parts(); + let (request, traversal_params) = self.request.clone().into_parts(); let actor_id = iter::once(self.actor_id) .chain(mem::take(&mut self.settings.parameters.actor_id)) .sorted_by_key(|actor_id| Uuid::from(*actor_id)) .dedup(); - let limit = iter::once(options.limit) + let limit = iter::once(request.limit) .chain( mem::take(&mut self.settings.parameters.limit) .into_iter() @@ -266,7 +261,7 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { ) .sorted() .dedup(); - let include_count = iter::once(options.include_count) + let include_count = iter::once(request.include_count) .chain(mem::take(&mut self.settings.parameters.include_count)) .sorted() .dedup(); @@ -292,11 +287,10 @@ impl QueryEntitySubgraphQuery<'_, '_, '_> { Self { actor_id, request: QueryEntitySubgraphRequest::from_parts( - query.clone(), - EntityQueryOptions { + QueryEntitiesRequest { limit, include_count, - ..options.clone() + ..request.clone() }, traversal_params, ), @@ -342,32 +336,24 @@ where match request { GraphQuery::QueryEntities(request) => { - let (query, options) = request.request.into_parts(); - let rest::entity::EntityQuery::Filter { filter } = query else { - panic!("unsupported query type") - }; - let _response = store .query_entities( request.actor_id, - options - .into_params(filter, config) + request + .request + .into_params(config) .expect("limit should not exceed configured maximum"), ) .await .expect("failed to read entities from store"); } GraphQuery::QueryEntitySubgraph(request) => { - let (query, options, traversal) = request.request.into_parts(); - let rest::entity::EntityQuery::Filter { filter } = query else { - panic!("unsupported query type") - }; - let _response = store .query_entity_subgraph( request.actor_id, - options - .into_traversal_params(filter, traversal, config) + request + .request + .into_traversal_params(config) .expect("limit should not exceed configured maximum"), ) .await From 109d3b3056729529a8f04304b00cd2af06ecbffc Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:37:57 +0200 Subject: [PATCH 2/8] chore: cleanup --- libs/@local/graph/api/src/rest/entity.rs | 804 ---------------- .../api/src/rest/entity_query_request.rs | 873 ------------------ libs/@local/graph/api/src/rest/mod.rs | 1 - 3 files changed, 1678 deletions(-) delete mode 100644 libs/@local/graph/api/src/rest/entity.rs delete mode 100644 libs/@local/graph/api/src/rest/entity_query_request.rs diff --git a/libs/@local/graph/api/src/rest/entity.rs b/libs/@local/graph/api/src/rest/entity.rs deleted file mode 100644 index 28b88f015db..00000000000 --- a/libs/@local/graph/api/src/rest/entity.rs +++ /dev/null @@ -1,804 +0,0 @@ -//! Web routes for CRU operations on entities. - -use alloc::sync::Arc; -use std::collections::HashMap; - -use axum::{Extension, Router, routing::post}; -use error_stack::{Report, ResultExt as _}; -use hash_graph_authorization::policies::principal::actor::AuthenticatedActor; -use hash_graph_postgres_store::store::error::{EntityDoesNotExist, RaceConditionOnUpdate}; -use hash_graph_store::{ - self, - entity::{ - ClosedMultiEntityTypeMap, CountEntitiesParams, CreateEntityParams, DiffEntityParams, - DiffEntityResult, EntityPermissions, EntityQueryCursor, EntityQuerySortingRecord, - EntityQuerySortingToken, EntityQueryToken, EntityStore, EntityTypesError, - EntityValidationReport, EntityValidationType, HasPermissionForEntitiesParams, - LinkDataStateError, LinkDataValidationReport, LinkError, LinkTargetError, - LinkValidationReport, LinkedEntityError, MetadataValidationReport, PatchEntityParams, - PropertyMetadataValidationReport, QueryConversion, QueryEntitiesResponse, - UnexpectedEntityType, UpdateEntityEmbeddingsParams, ValidateEntityComponents, - ValidateEntityParams, - }, - entity_type::EntityTypeResolveDefinitions, - pool::StorePool, - query::{NullOrdering, Ordering}, -}; -use hash_graph_types::{ - Embedding, - knowledge::{ - entity::EntityEmbedding, - property::visitor::{ - ArrayItemNumberMismatch, ArrayValidationReport, DataTypeCanonicalCalculation, - DataTypeConversionError, DataTypeInferenceError, JsonSchemaValueTypeMismatch, - ObjectPropertyValidationReport, ObjectValidationReport, OneOfArrayValidationReports, - OneOfObjectValidationReports, OneOfPropertyValidationReports, - PropertyArrayValidationReport, PropertyObjectValidationReport, - PropertyValidationReport, PropertyValueTypeMismatch, PropertyValueValidationReport, - ValueValidationError, ValueValidationReport, - }, - }, -}; -use hash_temporal_client::TemporalClient; -use hashql_core::heap::Heap; -use serde::{Deserialize as _, Serialize}; -use serde_json::value::RawValue as RawJsonvalue; -use type_system::{ - knowledge::{ - Confidence, Entity, Property, - entity::{ - EntityMetadata, LinkData, - id::{EntityEditionId, EntityId, EntityRecordId, EntityUuid}, - metadata::{EntityTemporalMetadata, EntityTypeIdDiff}, - provenance::{ - EntityDeletionProvenance, EntityEditionProvenance, EntityProvenance, - InferredEntityProvenance, ProvidedEntityEditionProvenance, - }, - }, - property::{ - PropertyArrayWithMetadata, PropertyDiff, PropertyObject, PropertyObjectWithMetadata, - PropertyPatchOperation, PropertyPath, PropertyPathElement, PropertyValueWithMetadata, - PropertyWithMetadata, - metadata::{ - ArrayMetadata, ObjectMetadata, PropertyArrayMetadata, PropertyMetadata, - PropertyObjectMetadata, PropertyProvenance, PropertyValueMetadata, - }, - }, - value::{ValueMetadata, metadata::ValueProvenance}, - }, - ontology::VersionedUrl, - principal::{ - actor::{ActorEntityUuid, ActorType}, - actor_group::WebId, - }, - provenance::{Location, OriginProvenance, SourceProvenance, SourceType}, -}; -use utoipa::{OpenApi, ToSchema}; - -pub use crate::rest::entity_query_request::{ - EntityQuery, EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest, -}; -use crate::rest::{ - ApiConfig, AuthenticatedUserHeader, InteractiveHeader, OpenApiQuery, QueryLogger, - entity_query_request::CompilationOptions, - json::Json, - status::{BoxedResponse, report_to_response}, - utoipa_typedef::subgraph::Subgraph, -}; - -#[derive(OpenApi)] -#[openapi( - paths( - create_entity, - create_entities, - validate_entity, - has_permission_for_entities, - query_entities, - query_entity_subgraph, - count_entities, - patch_entity, - update_entity_embeddings, - diff_entity, - ), - components( - schemas( - CreateEntityParams, - PropertyWithMetadata, - PropertyValueWithMetadata, - PropertyArrayWithMetadata, - PropertyObjectWithMetadata, - ValidateEntityParams, - CountEntitiesParams, - EntityValidationType, - ValidateEntityComponents, - Embedding, - UpdateEntityEmbeddingsParams, - EntityEmbedding, - EntityQueryToken, - - PatchEntityParams, - PropertyPatchOperation, - - HasPermissionForEntitiesParams, - - EntityQueryOptions, - QueryEntitiesRequest, - QueryEntitySubgraphRequest, - EntityQueryCursor, - Ordering, - NullOrdering, - EntityQuerySortingRecord, - EntityQuerySortingToken, - QueryEntitiesResponse, - QueryEntitySubgraphResponse, - EntityPermissions, - ClosedMultiEntityTypeMap, - QueryConversion, - - Entity, - Property, - PropertyProvenance, - PropertyObject, - ArrayMetadata, - ObjectMetadata, - ValueMetadata, - ValueProvenance, - PropertyObjectMetadata, - PropertyArrayMetadata, - PropertyValueMetadata, - PropertyMetadata, - EntityUuid, - EntityId, - EntityEditionId, - EntityMetadata, - EntityProvenance, - EntityDeletionProvenance, - EntityEditionProvenance, - InferredEntityProvenance, - ProvidedEntityEditionProvenance, - ActorType, - OriginProvenance, - SourceType, - SourceProvenance, - Location, - EntityRecordId, - EntityTemporalMetadata, - EntityQueryToken, - LinkData, - EntityValidationReport, - LinkedEntityError, - LinkDataValidationReport, - LinkDataStateError, - LinkValidationReport, - LinkError, - LinkTargetError, - UnexpectedEntityType, - MetadataValidationReport, - EntityTypesError, - PropertyMetadataValidationReport, - ObjectPropertyValidationReport, - JsonSchemaValueTypeMismatch, - ArrayValidationReport, - ArrayItemNumberMismatch, - PropertyValidationReport, - OneOfPropertyValidationReports, - PropertyValueValidationReport, - ObjectValidationReport, - DataTypeConversionError, - DataTypeCanonicalCalculation, - DataTypeInferenceError, - PropertyValueTypeMismatch, - OneOfArrayValidationReports, - PropertyArrayValidationReport, - OneOfObjectValidationReports, - PropertyObjectValidationReport, - ValueValidationReport, - ValueValidationError, - - DiffEntityParams, - DiffEntityResult, - EntityTypeIdDiff, - PropertyDiff, - PropertyPath, - PropertyPathElement, - Confidence, - ) - ), - tags( - (name = "Entity", description = "entity management API") - ) -)] -pub(crate) struct EntityResource; - -impl EntityResource { - /// Create routes for interacting with entities. - pub(crate) fn routes() -> Router - where - S: StorePool + Send + Sync + 'static, - { - // TODO: The URL format here is preliminary and will have to change. - Router::new().nest( - "/entities", - Router::new() - .route("/", post(create_entity::).patch(patch_entity::)) - .route("/bulk", post(create_entities::)) - .route("/diff", post(diff_entity::)) - .route("/validate", post(validate_entity::)) - .route("/embeddings", post(update_entity_embeddings::)) - .route("/permissions", post(has_permission_for_entities::)) - .nest( - "/query", - Router::new() - .route("/", post(query_entities::)) - .route("/subgraph", post(query_entity_subgraph::)) - .route("/count", post(count_entities::)), - ), - ) - } -} - -#[utoipa::path( - post, - path = "/entities", - request_body = CreateEntityParams, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, content_type = "application/json", description = "The created entity", body = Entity), - (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), - - (status = 404, description = "Entity Type URL was not found"), - (status = 500, description = "Store error occurred"), - ), -)] -async fn create_entity( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Json(body): Json, -) -> Result, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - let params = CreateEntityParams::deserialize(&body) - .map_err(Report::from) - .map_err(report_to_response)?; - - let mut store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - store - .create_entity(actor_id, params) - .await - .map_err(report_to_response) - .map(Json) -} - -#[utoipa::path( - post, - path = "/entities/bulk", - request_body = [CreateEntityParams], - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, content_type = "application/json", description = "The created entities", body = [Entity]), - (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), - - (status = 404, description = "Entity Type URL was not found"), - (status = 500, description = "Store error occurred"), - ), -)] -async fn create_entities( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Json(body): Json, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - let params = Vec::::deserialize(&body) - .map_err(Report::from) - .map_err(report_to_response)?; - - let mut store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - store - .create_entities(actor_id, params) - .await - .map_err(report_to_response) - .map(Json) -} - -#[utoipa::path( - post, - path = "/entities/validate", - request_body = ValidateEntityParams, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, content_type = "application/json", description = "The validation report", body = HashMap), - (status = 400, content_type = "application/json", description = "The entity validation failed"), - - (status = 404, description = "Entity Type URL was not found"), - (status = 500, description = "Store error occurred"), - ), -)] -async fn validate_entity( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - mut query_logger: Option>, - Json(body): Json, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::ValidateEntity(&body)); - } - - let params = ValidateEntityParams::deserialize(&body) - .map_err(Report::from) - .map_err(report_to_response)?; - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let response = store - .validate_entity(actor_id, params) - .await - .map_err(report_to_response) - .map(Json); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[utoipa::path( - post, - path = "/entities/permissions", - tag = "Entity", - request_body = HasPermissionForEntitiesParams, - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, body = HashMap>, description = "Information if the actor has the permission for the entities"), - - (status = 500, description = "Internal error occurred"), - ) -)] -async fn has_permission_for_entities( - AuthenticatedUserHeader(actor): AuthenticatedUserHeader, - temporal_client: Extension>>, - store_pool: Extension>, - Json(params): Json>, -) -> Result>>, BoxedResponse> -where - S: StorePool + Send + Sync, - for<'p> S::Store<'p>: EntityStore, -{ - store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)? - .has_permission_for_entities(AuthenticatedActor::from(actor), params) - .await - .map(Json) - .map_err(report_to_response) -} - -#[utoipa::path( - post, - path = "/entities/query", - request_body = QueryEntitiesRequest, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ("Interactive" = Option, Header, description = "Whether the request is used interactively"), - ("after" = Option, Query, description = "The cursor to start reading from"), - ("limit" = Option, Query, description = "The maximum number of entities to read"), - ), - responses( - ( - status = 200, - content_type = "application/json", - body = QueryEntitiesResponse, - description = "A list of entities that satisfy the given query.", - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn query_entities( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - InteractiveHeader(interactive): InteractiveHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Extension(api_config): Extension, - mut query_logger: Option>, - Json(request): Json>, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::GetEntities(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let request = QueryEntitiesRequest::deserialize(&*request) - .map_err(Report::from) - .map_err(report_to_response)?; - - let (query, options) = request.into_parts(); - - // TODO: https://linear.app/hash/issue/H-5351/reuse-parts-between-compilation-units - let mut heap = Heap::uninitialized(); - - if matches!(query, EntityQuery::Query { .. }) { - // The heap is going to be used in the compilation of the query and therefore needs to be - // primed. - // Doing this in a separate step allows us to be allocation free when not using HashQL - // queries. - heap.prime(); - } - - let filter = query.compile(&heap, CompilationOptions { interactive })?; - - let params = options - .into_params(filter, api_config) - .attach(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - let response = store - .query_entities(actor_id, params) - .await - .map(Json) - .map_err(report_to_response); - - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[derive(Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -struct QueryEntitySubgraphResponse<'r> { - subgraph: Subgraph, - #[serde(borrow)] - cursor: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - count: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - closed_multi_entity_types: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - definitions: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - web_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - edition_created_by_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - type_ids: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - type_titles: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - #[schema(nullable = false)] - entity_permissions: Option>, -} - -#[utoipa::path( - post, - path = "/entities/query/subgraph", - request_body = QueryEntitySubgraphRequest, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ("Interactive" = Option, Header, description = "Whether the query is interactive"), - ("after" = Option, Query, description = "The cursor to start reading from"), - ("limit" = Option, Query, description = "The maximum number of entities to read"), - ), - responses( - ( - status = 200, - content_type = "application/json", - body = QueryEntitySubgraphResponse, - description = "A subgraph rooted at entities that satisfy the given query, each resolved to the requested depth.", - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn query_entity_subgraph( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - InteractiveHeader(interactive): InteractiveHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Extension(api_config): Extension, - mut query_logger: Option>, - Json(request): Json, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::GetEntitySubgraph(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let request = QueryEntitySubgraphRequest::deserialize(&request) - .map_err(Report::from) - .map_err(report_to_response)?; - let (query, options, traversal) = request.into_parts(); - - // TODO: https://linear.app/hash/issue/H-5351/reuse-parts-between-compilation-units - let mut heap = Heap::uninitialized(); - - if matches!(query, EntityQuery::Query { .. }) { - // The heap is going to be used in the compilation of the query and therefore needs to be - // primed. - // Doing this in a separate step allows us to be allocation free when not using HashQL - // queries. - heap.prime(); - } - - let filter = query.compile(&heap, CompilationOptions { interactive })?; - - let params = options - .into_traversal_params(filter, traversal, api_config) - .attach(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - let response = store - .query_entity_subgraph(actor_id, params) - .await - .map(|response| { - Json(QueryEntitySubgraphResponse { - subgraph: response.subgraph.into(), - cursor: response.cursor.map(EntityQueryCursor::into_owned), - count: response.count, - closed_multi_entity_types: response.closed_multi_entity_types, - definitions: response.definitions, - web_ids: response.web_ids, - created_by_ids: response.created_by_ids, - edition_created_by_ids: response.edition_created_by_ids, - type_ids: response.type_ids, - type_titles: response.type_titles, - entity_permissions: response.entity_permissions, - }) - }) - .map_err(report_to_response); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[utoipa::path( - post, - path = "/entities/query/count", - request_body = CountEntitiesParams, - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - - ), - responses( - ( - status = 200, - content_type = "application/json", - body = usize, - ), - (status = 422, content_type = "text/plain", description = "Provided query is invalid"), - (status = 500, description = "Store error occurred"), - ) -)] -async fn count_entities( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - mut query_logger: Option>, - Json(request): Json, -) -> Result, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::CountEntities(&request)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let response = store - .count_entities( - actor_id, - CountEntitiesParams::deserialize(&request) - .map_err(Report::from) - .map_err(report_to_response)?, - ) - .await - .map(Json) - .map_err(report_to_response); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} - -#[utoipa::path( - patch, - path = "/entities", - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, content_type = "application/json", description = "The updated entity", body = Entity), - (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), - (status = 423, content_type = "text/plain", description = "The entity that should be updated was unexpectedly updated at the same time"), - - (status = 404, description = "Entity ID or Entity Type URL was not found"), - (status = 500, description = "Store error occurred"), - ), - request_body = PatchEntityParams, -)] -async fn patch_entity( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Json(params): Json, -) -> Result, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - let mut store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - store - .patch_entity(actor_id, params) - .await - .map_err(|report| { - if report.contains::() { - report.attach_opaque(hash_status::StatusCode::NotFound) - } else if report.contains::() { - report.attach_opaque(hash_status::StatusCode::Cancelled) - } else { - report - } - }) - .map_err(report_to_response) - .map(Json) -} - -#[utoipa::path( - post, - path = "/entities/embeddings", - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 204, content_type = "application/json", description = "The embeddings were created"), - - (status = 403, description = "Insufficient permissions to update the entity"), - (status = 500, description = "Store error occurred"), - ), - request_body = UpdateEntityEmbeddingsParams, -)] -async fn update_entity_embeddings( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - Json(body): Json, -) -> Result<(), BoxedResponse> -where - S: StorePool + Send + Sync, -{ - // Manually deserialize the request from a JSON value to allow borrowed deserialization and - // better error reporting. - let params = UpdateEntityEmbeddingsParams::deserialize(body) - .attach_opaque(hash_status::StatusCode::InvalidArgument) - .map_err(report_to_response)?; - - let mut store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - store - .update_entity_embeddings(actor_id, params) - .await - .map_err(report_to_response) -} - -#[utoipa::path( - post, - path = "/entities/diff", - tag = "Entity", - params( - ("X-Authenticated-User-Actor-Id" = ActorEntityUuid, Header, description = "The ID of the actor which is used to authorize the request"), - ), - responses( - (status = 200, content_type = "application/json", description = "The difference between the two entities", body = DiffEntityResult), - (status = 422, content_type = "text/plain", description = "Provided request body is invalid"), - - (status = 404, description = "Entity ID was not found"), - (status = 500, description = "Store error occurred"), - ), - request_body = DiffEntityParams, -)] -async fn diff_entity( - AuthenticatedUserHeader(actor_id): AuthenticatedUserHeader, - store_pool: Extension>, - temporal_client: Extension>>, - mut query_logger: Option>, - Json(params): Json, -) -> Result>, BoxedResponse> -where - S: StorePool + Send + Sync, -{ - if let Some(query_logger) = &mut query_logger { - query_logger.capture(actor_id, OpenApiQuery::DiffEntity(¶ms)); - } - - let store = store_pool - .acquire(temporal_client.0) - .await - .map_err(report_to_response)?; - - let response = store - .diff_entity(actor_id, params) - .await - .map_err(|report| { - if report.contains::() { - report.attach_opaque(hash_status::StatusCode::NotFound) - } else { - report - } - }) - .map_err(report_to_response) - .map(Json); - if let Some(query_logger) = &mut query_logger { - query_logger.send().await.map_err(report_to_response)?; - } - response -} diff --git a/libs/@local/graph/api/src/rest/entity_query_request.rs b/libs/@local/graph/api/src/rest/entity_query_request.rs deleted file mode 100644 index a4710c5a028..00000000000 --- a/libs/@local/graph/api/src/rest/entity_query_request.rs +++ /dev/null @@ -1,873 +0,0 @@ -//! Request types for entity queries. -//! -//! Contains the deserialization structs for both simple entity queries and subgraph requests. -//! Some design choices may look odd due to serde/OpenAPI limitations we need to work around: -//! -//! - Uses proxy structs for deserialization because `RawValue` doesn't play nice with `untagged` + -//! `deny_unknown_fields` (forces intermediate representation). -//! - Subgraph enum has 4 variants instead of nested structs because openapi-generator uses `&` -//! instead of `|` for nested `oneOf` constraints. -//! - Outer enum instead of nested enum because utoipa generates `allOf` constraints (merges all -//! fields into one type). With discriminator on the outer edge we get `oneOf` (proper union), but -//! openapi-generator can't handle nested oneOf and merges them anyway - so we flatten everything -//! - Lots of boolean fields instead of option structs for the same reason -//! -//! When changing any of these types, make sure that the OpenAPI generator types do not degenerate -//! into any of these cases. -#![expect( - dead_code, - reason = "https://linear.app/hash/issue/BE-537/hashql-remove-old-backend-wire-up-hashql-in-the-api" -)] -use alloc::borrow::Cow; -use core::{cmp, ops::Range}; - -use axum::{ - Json, - response::{Html, IntoResponse as _}, -}; -use error_stack::Report; -use hash_graph_store::{ - entity::{ - EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord, - QueryConversion, QueryEntitiesParams, QueryEntitySubgraphParams, - }, - entity_type::IncludeEntityTypeOption, - filter::Filter, - query::Ordering, - subgraph::{ - edges::{ - EntityTraversalPath, GraphResolveDepths, MAX_TRAVERSAL_PATHS, - ResolveDepthExceededError, SubgraphTraversalParams, SubgraphTraversalValidationError, - TraversalDepthError, TraversalPath, TraversalPathConversionError, - }, - temporal_axes::QueryTemporalAxesUnresolved, - }, -}; -use hashql_ast::error::AstDiagnosticCategory; -use hashql_core::{ - heap::Heap, - span::{SpanId, SpanTable}, -}; -use hashql_diagnostics::{ - DiagnosticIssues, Failure, Severity, - category::{DiagnosticCategory, canonical_category_id}, - diagnostic::render::{Format, RenderOptions}, - source::{DiagnosticSpan, Source, Sources}, -}; -use hashql_eval::error::EvalDiagnosticCategory; -use hashql_hir::error::HirDiagnosticCategory; -use hashql_syntax_jexpr::{error::JExprDiagnosticCategory, span::Span}; -use http::StatusCode; -use serde::Deserialize; -use serde_json::value::RawValue as RawJsonValue; -use type_system::knowledge::Entity; -use utoipa::ToSchema; - -use super::{ApiConfig, LimitExceededError, resolve_limit, status::BoxedResponse}; - -#[tracing::instrument(level = "info", skip_all)] -fn generate_sorting_paths( - paths: Option>>, - temporal_axes: &QueryTemporalAxesUnresolved, -) -> Vec> { - let temporal_axes_sorting_path = match temporal_axes { - QueryTemporalAxesUnresolved::TransactionTime { .. } => &EntityQueryPath::TransactionTime, - QueryTemporalAxesUnresolved::DecisionTime { .. } => &EntityQueryPath::DecisionTime, - }; - - paths - .map_or_else( - || { - vec![ - EntityQuerySortingRecord { - path: temporal_axes_sorting_path.clone(), - ordering: Ordering::Descending, - nulls: None, - }, - EntityQuerySortingRecord { - path: EntityQueryPath::Uuid, - ordering: Ordering::Ascending, - nulls: None, - }, - EntityQuerySortingRecord { - path: EntityQueryPath::WebId, - ordering: Ordering::Ascending, - nulls: None, - }, - ] - }, - |mut paths| { - let mut has_temporal_axis = false; - let mut has_uuid = false; - let mut has_web_id = false; - - for path in &paths { - if path.path == EntityQueryPath::TransactionTime - || path.path == EntityQueryPath::DecisionTime - { - has_temporal_axis = true; - } - if path.path == EntityQueryPath::Uuid { - has_uuid = true; - } - if path.path == EntityQueryPath::WebId { - has_web_id = true; - } - } - - if !has_temporal_axis { - paths.push(EntityQuerySortingRecord { - path: temporal_axes_sorting_path.clone(), - ordering: Ordering::Descending, - nulls: None, - }); - } - if !has_uuid { - paths.push(EntityQuerySortingRecord { - path: EntityQueryPath::Uuid, - ordering: Ordering::Ascending, - nulls: None, - }); - } - if !has_web_id { - paths.push(EntityQuerySortingRecord { - path: EntityQueryPath::WebId, - ordering: Ordering::Ascending, - nulls: None, - }); - } - - paths - }, - ) - .into_iter() - .map(EntityQuerySortingRecord::into_owned) - .collect() -} - -/// Internal deserialization proxy for `QueryEntitiesRequest`. -/// -/// This struct is necessary because [`RawJsonValue`] cannot be used directly with -/// `#[serde(untagged, deny_unknown_fields)]` - these attributes force deserialization into an -/// intermediate representation, which cannot deserialize into a [`RawJsonValue`] as it materializes -/// the content. -/// -/// See and for more details. -#[derive(Debug, Clone, Deserialize)] -#[expect( - clippy::struct_excessive_bools, - reason = "Parameter struct deserialized from JSON" -)] -#[serde(rename_all = "camelCase")] -struct FlatQueryEntitiesRequestData<'q, 's, 'p> { - // `QueryEntitiesQuery::Filter` - #[serde(borrow)] - filter: Option>, - // `QueryEntitiesQuery::Query`, - #[serde(borrow)] - query: Option<&'q RawJsonValue>, - - // `QueryEntitiesRequest` - temporal_axes: QueryTemporalAxesUnresolved, - include_drafts: bool, - limit: Option, - #[serde(borrow, default)] - conversions: Vec>, - #[serde(borrow)] - sorting_paths: Option>>, - #[serde(borrow)] - cursor: Option>, - #[serde(default)] - include_count: bool, - #[serde(default)] - include_entity_types: Option, - #[serde(default)] - include_web_ids: bool, - #[serde(default)] - include_created_by_ids: bool, - #[serde(default)] - include_edition_created_by_ids: bool, - #[serde(default)] - include_type_ids: bool, - #[serde(default)] - include_type_titles: bool, - include_permissions: bool, - - traversal_paths: Option>, - graph_resolve_depths: Option, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -pub(crate) struct CompilationOptions { - pub interactive: bool, -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash)] -enum HashQLDiagnosticCategory { - JExpr(JExprDiagnosticCategory), - Ast(AstDiagnosticCategory), - Hir(HirDiagnosticCategory), - Eval(EvalDiagnosticCategory), -} - -impl serde::Serialize for HashQLDiagnosticCategory { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.collect_str(&canonical_category_id(self)) - } -} - -impl DiagnosticCategory for HashQLDiagnosticCategory { - fn id(&self) -> Cow<'_, str> { - Cow::Borrowed("hashql") - } - - fn name(&self) -> Cow<'_, str> { - Cow::Borrowed("HashQL") - } - - fn subcategory(&self) -> Option<&dyn DiagnosticCategory> { - match self { - Self::JExpr(jexpr) => Some(jexpr), - Self::Ast(ast) => Some(ast), - Self::Hir(hir) => Some(hir), - Self::Eval(eval) => Some(eval), - } - } -} - -#[derive(Debug, serde::Serialize)] -struct ResolvedSpan { - pub range: Range, - pub pointer: Option, -} - -fn resolve_span(id: SpanId, mut spans: &SpanTable) -> Option { - let absolute = DiagnosticSpan::absolute(&id, &mut spans)?; - let mut pointer = spans.get(id)?.pointer.as_ref().map(ToString::to_string); - - for ancestor in spans.ancestors(id) { - let Some(ancestor) = spans.get(ancestor) else { - continue; - }; - - if pointer.is_none() - && let Some(ancestor_pointer) = &ancestor.pointer - { - pointer = Some(ancestor_pointer.to_string()); - } - } - - Some(ResolvedSpan { - range: absolute.range().into(), - pointer, - }) -} - -fn issues_to_response( - issues: DiagnosticIssues, - severity: Severity, - source: &str, - mut spans: &SpanTable, - options: CompilationOptions, -) -> BoxedResponse { - let status_code = match severity { - Severity::Bug | Severity::Fatal => StatusCode::INTERNAL_SERVER_ERROR, - Severity::Error => StatusCode::BAD_REQUEST, - Severity::Warning | Severity::Note | Severity::Debug => StatusCode::CONFLICT, - }; - - let mut sources = Sources::new(); - sources.push(Source::new(source)); - - let mut response = if options.interactive { - let output = issues.render(RenderOptions::new(Format::Html, &sources), &mut spans); - - Html(output).into_response() - } else { - let diagnostics: Vec<_> = issues - .into_iter() - .map(|diagnostic| diagnostic.map_spans(|span| resolve_span(span, spans))) - .collect(); - - Json(diagnostics).into_response() - }; - - *response.status_mut() = status_code; - response.into() -} - -fn failure_to_response( - failure: Failure, - source: &str, - spans: &SpanTable, - options: CompilationOptions, -) -> BoxedResponse { - // Find the highest diagnostic level - let severity = cmp::max( - failure - .secondary - .iter() - .map(|diagnostic| diagnostic.severity) - .max() - .unwrap_or(Severity::Debug), - failure.primary.severity.into(), - ); - - issues_to_response(failure.into_issues(), severity, source, spans, options) -} - -#[derive(Debug, Clone)] -#[expect(clippy::large_enum_variant)] -pub enum EntityQuery<'q> { - Filter { filter: Filter<'q, Entity> }, - Query { query: &'q RawJsonValue }, -} - -impl<'q> EntityQuery<'q> { - /// Compiles a query into an executable entity filter. - /// - /// Transforms the query representation into a [`Filter`] that can be executed - /// against the entity store. For already-compiled filter queries, this returns - /// the filter directly. For raw HashQL queries, it parses and compiles them using - /// the provided `heap` arena allocator. - /// - /// # Errors - /// - /// Returns an error if the HashQL query cannot be compiled. - pub(crate) fn compile( - self, - _: &'q Heap, - _: CompilationOptions, - ) -> Result, BoxedResponse> { - match self { - EntityQuery::Filter { filter } => Ok(filter), - EntityQuery::Query { query: _ } => { - let response = (StatusCode::NOT_IMPLEMENTED, "https://linear.app/hash/issue/BE-537/hashql-remove-old-backend-wire-up-hashql-in-the-api").into_response(); - Err(response.into()) - } - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display)] -pub enum EntityQueryOptionsError { - #[display( - "Field '{field}' is only valid in subgraph requests. Use the subgraph endpoint instead." - )] - InvalidFieldForEntityQuery { field: &'static str }, - #[display( - "Field '{field}' is only valid in entity and subgraph requests. Use the entity endpoint \ - instead." - )] - InvalidFieldForEntityOptions { field: &'static str }, -} - -impl core::error::Error for EntityQueryOptionsError {} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde(rename_all = "camelCase", deny_unknown_fields)] -#[expect( - clippy::struct_excessive_bools, - reason = "Parameter struct deserialized from JSON" -)] -pub struct EntityQueryOptions<'s, 'p> { - pub temporal_axes: QueryTemporalAxesUnresolved, - pub include_drafts: bool, - pub limit: Option, - #[serde(borrow, default)] - pub conversions: Vec>, - #[serde(borrow)] - pub sorting_paths: Option>>, - #[serde(borrow)] - pub cursor: Option>, - #[serde(default)] - pub include_count: bool, - #[serde(default)] - pub include_entity_types: Option, - #[serde(default)] - pub include_web_ids: bool, - #[serde(default)] - pub include_created_by_ids: bool, - #[serde(default)] - pub include_edition_created_by_ids: bool, - #[serde(default)] - pub include_type_ids: bool, - #[serde(default)] - pub include_type_titles: bool, - pub include_permissions: bool, -} - -impl<'q, 's, 'p> TryFrom> for EntityQueryOptions<'s, 'p> { - type Error = EntityQueryOptionsError; - - fn try_from(value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let FlatQueryEntitiesRequestData { - filter, - query, - temporal_axes, - include_drafts, - limit, - conversions, - sorting_paths, - cursor, - include_count, - include_entity_types, - include_web_ids, - include_created_by_ids, - include_edition_created_by_ids, - include_type_ids, - include_type_titles, - include_permissions, - graph_resolve_depths, - traversal_paths, - } = value; - - if filter.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityOptions { field: "filter" }); - } - - if query.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityOptions { field: "query" }); - } - - if graph_resolve_depths.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityQuery { - field: "graphResolveDepths", - }); - } - - if traversal_paths.is_some() { - return Err(EntityQueryOptionsError::InvalidFieldForEntityQuery { - field: "traversalPaths", - }); - } - - Ok(Self { - temporal_axes, - include_drafts, - limit, - conversions, - sorting_paths, - cursor, - include_count, - include_entity_types, - include_web_ids, - include_created_by_ids, - include_edition_created_by_ids, - include_type_ids, - include_type_titles, - include_permissions, - }) - } -} - -impl<'p> EntityQueryOptions<'_, 'p> { - /// # Errors - /// - /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in - /// [`ApiConfig::query_entity_limit`]. - pub fn into_params<'f>( - self, - filter: Filter<'f, Entity>, - config: ApiConfig, - ) -> Result, Report> - where - 'p: 'f, - { - let limit = resolve_limit(self.limit, config.query_entity_limit)?; - - Ok(QueryEntitiesParams { - filter, - sorting: EntityQuerySorting { - paths: generate_sorting_paths(self.sorting_paths, &self.temporal_axes), - cursor: self.cursor.map(EntityQueryCursor::into_owned), - }, - limit, - conversions: self.conversions, - include_drafts: self.include_drafts, - include_count: self.include_count, - include_entity_types: self.include_entity_types, - temporal_axes: self.temporal_axes, - include_web_ids: self.include_web_ids, - include_created_by_ids: self.include_created_by_ids, - include_edition_created_by_ids: self.include_edition_created_by_ids, - include_type_ids: self.include_type_ids, - include_type_titles: self.include_type_titles, - include_permissions: self.include_permissions, - }) - } - - /// # Errors - /// - /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in - /// [`ApiConfig::query_entity_limit`]. - pub fn into_traversal_params<'q>( - self, - filter: Filter<'q, Entity>, - traversal: SubgraphTraversalParams, - config: ApiConfig, - ) -> Result, Report> - where - 'p: 'q, - { - match traversal { - SubgraphTraversalParams::Paths { traversal_paths } => { - Ok(QueryEntitySubgraphParams::Paths { - traversal_paths, - request: self.into_params(filter, config)?, - }) - } - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - } => Ok(QueryEntitySubgraphParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - request: self.into_params(filter, config)?, - }), - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display, derive_more::From)] -enum QueryEntitiesRequestError { - #[from] - RequestOptions(EntityQueryOptionsError), - #[display("Missing required query parameter. Provide either 'filter' or 'query'.")] - MissingQueryParameter, - #[display("Conflicting query parameters. Provide either 'filter' or 'query', not both.")] - ConflictingQueryParameters, -} - -impl core::error::Error for QueryEntitiesRequestError {} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde( - untagged, - try_from = "FlatQueryEntitiesRequestData", - deny_unknown_fields -)] -#[expect(clippy::large_enum_variant)] -pub enum QueryEntitiesRequest<'q, 's, 'p> { - #[serde(rename_all = "camelCase")] - Query { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - Filter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, -} - -impl<'q, 's, 'p> TryFrom> - for QueryEntitiesRequest<'q, 's, 'p> -{ - type Error = QueryEntitiesRequestError; - - fn try_from(mut value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let filter = value.filter.take(); - let query = value.query.take(); - - match (filter, query) { - (None, None) => Err(QueryEntitiesRequestError::MissingQueryParameter), - (Some(_), Some(_)) => Err(QueryEntitiesRequestError::ConflictingQueryParameters), - (Some(filter), None) => Ok(Self::Filter { - filter, - options: value.try_into()?, - }), - (None, Some(query)) => Ok(Self::Query { - query, - options: value.try_into()?, - }), - } - } -} - -impl<'q, 's, 'p> QueryEntitiesRequest<'q, 's, 'p> { - #[must_use] - pub fn from_parts(query: EntityQuery<'q>, options: EntityQueryOptions<'s, 'p>) -> Self { - match query { - EntityQuery::Filter { filter } => Self::Filter { filter, options }, - EntityQuery::Query { query } => Self::Query { query, options }, - } - } - - #[must_use] - pub fn into_parts(self) -> (EntityQuery<'q>, EntityQueryOptions<'s, 'p>) { - match self { - QueryEntitiesRequest::Query { query, options } => { - (EntityQuery::Query { query }, options) - } - QueryEntitiesRequest::Filter { filter, options } => { - (EntityQuery::Filter { filter }, options) - } - } - } -} - -#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display, derive_more::From)] -enum QueryEntitySubgraphRequestError { - #[from] - QueryEntityRequest(QueryEntitiesRequestError), - #[from] - UnsupportedGraphTraversalPath(TraversalPathConversionError), - #[display( - "Subgraph request missing traversal parameters. Specify either 'traversalPaths` and \ - optionally `graphResolveDepths'." - )] - MissingSubgraphTraversal, - #[from] - TraversalValidation(SubgraphTraversalValidationError), -} - -impl core::error::Error for QueryEntitySubgraphRequestError {} - -impl From for QueryEntitySubgraphRequestError { - fn from(err: TraversalDepthError) -> Self { - Self::TraversalValidation(err.into()) - } -} - -impl From for QueryEntitySubgraphRequestError { - fn from(err: ResolveDepthExceededError) -> Self { - Self::TraversalValidation(err.into()) - } -} - -#[derive(Debug, Clone, Deserialize, ToSchema)] -#[serde( - untagged, - try_from = "FlatQueryEntitiesRequestData", - deny_unknown_fields -)] -pub enum QueryEntitySubgraphRequest<'q, 's, 'p> { - #[serde(rename_all = "camelCase")] - ResolveDepthsWithQuery { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - traversal_paths: Vec, - graph_resolve_depths: GraphResolveDepths, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - ResolveDepthsWithFilter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - traversal_paths: Vec, - graph_resolve_depths: GraphResolveDepths, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - PathsWithQuery { - #[serde(borrow)] - #[schema(value_type = utoipa::openapi::schema::Value)] - query: &'q RawJsonValue, - traversal_paths: Vec, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, - #[serde(rename_all = "camelCase")] - PathsWithFilter { - #[serde(borrow)] - filter: Filter<'q, Entity>, - traversal_paths: Vec, - #[serde(borrow, flatten)] - options: EntityQueryOptions<'s, 'p>, - }, -} - -impl<'q, 's, 'p> TryFrom> - for QueryEntitySubgraphRequest<'q, 's, 'p> -{ - type Error = QueryEntitySubgraphRequestError; - - fn try_from(mut value: FlatQueryEntitiesRequestData<'q, 's, 'p>) -> Result { - let graph_resolve_depths = value.graph_resolve_depths.take(); - let traversal_paths = value - .traversal_paths - .take() - .ok_or(QueryEntitySubgraphRequestError::MissingSubgraphTraversal)?; - - if traversal_paths.len() > MAX_TRAVERSAL_PATHS { - return Err(TraversalDepthError::TooManyPaths { - actual: traversal_paths.len(), - max: MAX_TRAVERSAL_PATHS, - } - .into()); - } - - let request = value.try_into()?; - - match graph_resolve_depths { - None => { - for path in &traversal_paths { - path.validate()?; - } - match request { - QueryEntitiesRequest::Filter { filter, options } => { - Ok(QueryEntitySubgraphRequest::PathsWithFilter { - traversal_paths, - filter, - options, - }) - } - QueryEntitiesRequest::Query { query, options } => { - Ok(QueryEntitySubgraphRequest::PathsWithQuery { - traversal_paths, - query, - options, - }) - } - } - } - Some(graph_resolve_depths) => { - let entity_paths: Vec = traversal_paths - .into_iter() - .map(EntityTraversalPath::try_from) - .collect::>()?; - for path in &entity_paths { - path.validate()?; - } - graph_resolve_depths.validate()?; - match request { - QueryEntitiesRequest::Filter { filter, options } => { - Ok(QueryEntitySubgraphRequest::ResolveDepthsWithFilter { - traversal_paths: entity_paths, - graph_resolve_depths, - filter, - options, - }) - } - QueryEntitiesRequest::Query { query, options } => { - Ok(QueryEntitySubgraphRequest::ResolveDepthsWithQuery { - traversal_paths: entity_paths, - graph_resolve_depths, - query, - options, - }) - } - } - } - } - } -} - -impl<'q, 's, 'p> QueryEntitySubgraphRequest<'q, 's, 'p> { - #[must_use] - pub fn from_parts( - query: EntityQuery<'q>, - options: EntityQueryOptions<'s, 'p>, - traversal_params: SubgraphTraversalParams, - ) -> Self { - match (query, traversal_params) { - ( - EntityQuery::Filter { filter }, - SubgraphTraversalParams::Paths { traversal_paths }, - ) => Self::PathsWithFilter { - filter, - options, - traversal_paths, - }, - (EntityQuery::Query { query }, SubgraphTraversalParams::Paths { traversal_paths }) => { - Self::PathsWithQuery { - query, - traversal_paths, - options, - } - } - ( - EntityQuery::Filter { filter }, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ) => Self::ResolveDepthsWithFilter { - filter, - options, - traversal_paths, - graph_resolve_depths, - }, - ( - EntityQuery::Query { query }, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ) => Self::ResolveDepthsWithQuery { - query, - options, - traversal_paths, - graph_resolve_depths, - }, - } - } - - #[must_use] - pub fn into_parts( - self, - ) -> ( - EntityQuery<'q>, - EntityQueryOptions<'s, 'p>, - SubgraphTraversalParams, - ) { - match self { - QueryEntitySubgraphRequest::PathsWithQuery { - query, - traversal_paths, - options, - } => ( - EntityQuery::Query { query }, - options, - SubgraphTraversalParams::Paths { traversal_paths }, - ), - QueryEntitySubgraphRequest::PathsWithFilter { - filter, - traversal_paths, - options, - } => ( - EntityQuery::Filter { filter }, - options, - SubgraphTraversalParams::Paths { traversal_paths }, - ), - QueryEntitySubgraphRequest::ResolveDepthsWithQuery { - query, - traversal_paths, - graph_resolve_depths, - options, - } => ( - EntityQuery::Query { query }, - options, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ), - QueryEntitySubgraphRequest::ResolveDepthsWithFilter { - filter, - traversal_paths, - graph_resolve_depths, - options, - } => ( - EntityQuery::Filter { filter }, - options, - SubgraphTraversalParams::ResolveDepths { - traversal_paths, - graph_resolve_depths, - }, - ), - } - } -} diff --git a/libs/@local/graph/api/src/rest/mod.rs b/libs/@local/graph/api/src/rest/mod.rs index e776ccb6fe7..26deb299e9e 100644 --- a/libs/@local/graph/api/src/rest/mod.rs +++ b/libs/@local/graph/api/src/rest/mod.rs @@ -16,7 +16,6 @@ pub mod admin; pub mod http_tracing_layer; pub mod jwt; -mod entity_query_request; mod json; mod utoipa_typedef; use alloc::{borrow::Cow, sync::Arc}; From 32d04fb17858d716644f5b73fcf804948e17bba4 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:47:07 +0200 Subject: [PATCH 3/8] chore: remove unused file --- libs/@local/graph/api/src/rest/entity/query/filter.rs | 1 - libs/@local/graph/api/src/rest/entity/query/mod.rs | 1 - 2 files changed, 2 deletions(-) delete mode 100644 libs/@local/graph/api/src/rest/entity/query/filter.rs diff --git a/libs/@local/graph/api/src/rest/entity/query/filter.rs b/libs/@local/graph/api/src/rest/entity/query/filter.rs deleted file mode 100644 index 8b137891791..00000000000 --- a/libs/@local/graph/api/src/rest/entity/query/filter.rs +++ /dev/null @@ -1 +0,0 @@ - diff --git a/libs/@local/graph/api/src/rest/entity/query/mod.rs b/libs/@local/graph/api/src/rest/entity/query/mod.rs index 12c30fdb443..d788b3199e9 100644 --- a/libs/@local/graph/api/src/rest/entity/query/mod.rs +++ b/libs/@local/graph/api/src/rest/entity/query/mod.rs @@ -1,4 +1,3 @@ -pub(crate) mod filter; pub(crate) mod request; use alloc::sync::Arc; From 79d0cfe35e616ef3f18f99dd54b377f75b9f7db5 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 14:07:37 +0200 Subject: [PATCH 4/8] feat: add deny_unknown_fields --- .../api/src/rest/entity/query/request.rs | 77 ++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/entity/query/request.rs b/libs/@local/graph/api/src/rest/entity/query/request.rs index 4bf9f4292f9..deb76c5d73a 100644 --- a/libs/@local/graph/api/src/rest/entity/query/request.rs +++ b/libs/@local/graph/api/src/rest/entity/query/request.rs @@ -156,7 +156,7 @@ fn generate_sorting_paths( clippy::struct_excessive_bools, reason = "Parameter struct deserialized from JSON" )] -#[serde(rename_all = "camelCase")] +#[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct QueryEntitiesRequest<'q, 's, 'p> { #[serde(borrow)] pub filter: Filter<'q, Entity>, @@ -684,6 +684,81 @@ mod tests { ); } + #[test] + fn reject_entity_request_unknown_field() { + let payload = json!({ + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "bogusField": 42 + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field should be rejected") + .to_string(); + assert!(err.contains("bogusField"), "{err}"); + } + + #[test] + fn reject_subgraph_unknown_field_through_flatten() { + // The subgraph enum uses `#[serde(flatten)]` on the inner request. + // Verify that `deny_unknown_fields` still catches unknown keys that + // would pass through the flattened struct boundary. + let payload = json!({ + "traversalPaths": [ + { + "edges": [ + { "kind": "has-left-entity", "direction": "incoming" } + ] + } + ], + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "bogusField": 42 + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field through flatten should be rejected") + .to_string(); + // With untagged + flatten, serde reports "did not match any variant" + // because both variants reject the unknown field. + assert!( + err.contains("bogusField") || err.contains("did not match any variant"), + "{err}" + ); + } + + #[test] + fn reject_subgraph_resolve_depths_unknown_field_through_flatten() { + let payload = json!({ + "traversalPaths": [], + "graphResolveDepths": { + "inheritsFrom": 0, + "constrainsValuesOn": 0, + "constrainsPropertiesOn": 0, + "constrainsLinksOn": 0, + "constrainsLinkDestinationsOn": 0, + "isOfType": false + }, + "filter": { "all": [] }, + "temporalAxes": temporal_axes(), + "includeDrafts": false, + "includePermissions": false, + "sneakyExtra": true + }) + .to_string(); + let err = serde_json::from_str::>(&payload) + .expect_err("unknown field through flatten should be rejected") + .to_string(); + assert!( + err.contains("sneakyExtra") || err.contains("did not match any variant"), + "{err}" + ); + } + #[test] fn deserialize_subgraph_paths_with_traversal() { let payload = json!({ From 8e72607f2aa3d49d5a899249d613a20410db5d6b Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Mon, 8 Jun 2026 15:01:57 +0200 Subject: [PATCH 5/8] chore: regenerate openapi schema --- libs/@local/graph/api/openapi/openapi.json | 248 ++++++--------------- 1 file changed, 68 insertions(+), 180 deletions(-) diff --git a/libs/@local/graph/api/openapi/openapi.json b/libs/@local/graph/api/openapi/openapi.json index 3d4c93a04f7..7893ed31257 100644 --- a/libs/@local/graph/api/openapi/openapi.json +++ b/libs/@local/graph/api/openapi/openapi.json @@ -1648,16 +1648,6 @@ "$ref": "#/components/schemas/ActorEntityUuid" } }, - { - "name": "Interactive", - "in": "header", - "description": "Whether the request is used interactively", - "required": false, - "schema": { - "type": "boolean", - "nullable": true - } - }, { "name": "after", "in": "query", @@ -1776,16 +1766,6 @@ "$ref": "#/components/schemas/ActorEntityUuid" } }, - { - "name": "Interactive", - "in": "header", - "description": "Whether the query is interactive", - "required": false, - "schema": { - "type": "boolean", - "nullable": true - } - }, { "name": "after", "in": "query", @@ -4600,78 +4580,6 @@ "type": "object" } }, - "EntityQueryOptions": { - "type": "object", - "required": [ - "temporalAxes", - "includeDrafts", - "includePermissions" - ], - "properties": { - "conversions": { - "type": "array", - "items": { - "$ref": "#/components/schemas/QueryConversion" - } - }, - "cursor": { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryCursor" - } - ], - "nullable": true - }, - "includeCount": { - "type": "boolean" - }, - "includeCreatedByIds": { - "type": "boolean" - }, - "includeDrafts": { - "type": "boolean" - }, - "includeEditionCreatedByIds": { - "type": "boolean" - }, - "includeEntityTypes": { - "allOf": [ - { - "$ref": "#/components/schemas/IncludeEntityTypeOption" - } - ], - "nullable": true - }, - "includePermissions": { - "type": "boolean" - }, - "includeTypeIds": { - "type": "boolean" - }, - "includeTypeTitles": { - "type": "boolean" - }, - "includeWebIds": { - "type": "boolean" - }, - "limit": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "sortingPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityQuerySortingRecord" - }, - "nullable": true - }, - "temporalAxes": { - "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" - } - }, - "additionalProperties": false - }, "EntityQuerySortingPath": { "type": "array", "items": { @@ -7991,42 +7899,80 @@ } }, "QueryEntitiesRequest": { - "oneOf": [ - { + "type": "object", + "required": [ + "filter", + "temporalAxes", + "includeDrafts", + "includePermissions" + ], + "properties": { + "conversions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/QueryConversion" + } + }, + "cursor": { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query" - ], - "properties": { - "query": {} - } + "$ref": "#/components/schemas/EntityQueryCursor" } - ] + ], + "nullable": true }, - { + "filter": { + "$ref": "#/components/schemas/Filter" + }, + "includeCount": { + "type": "boolean" + }, + "includeCreatedByIds": { + "type": "boolean" + }, + "includeDrafts": { + "type": "boolean" + }, + "includeEditionCreatedByIds": { + "type": "boolean" + }, + "includeEntityTypes": { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "filter" - ], - "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - } - } + "$ref": "#/components/schemas/IncludeEntityTypeOption" } - ] + ], + "nullable": true + }, + "includePermissions": { + "type": "boolean" + }, + "includeTypeIds": { + "type": "boolean" + }, + "includeTypeTitles": { + "type": "boolean" + }, + "includeWebIds": { + "type": "boolean" + }, + "limit": { + "type": "integer", + "nullable": true, + "minimum": 0 + }, + "sortingPaths": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EntityQuerySortingRecord" + }, + "nullable": true + }, + "temporalAxes": { + "$ref": "#/components/schemas/QueryTemporalAxesUnresolved" } - ] + }, + "additionalProperties": false }, "QueryEntitiesResponse": { "type": "object", @@ -8112,46 +8058,15 @@ { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query", - "traversalPaths", - "graphResolveDepths" - ], - "properties": { - "graphResolveDepths": { - "$ref": "#/components/schemas/GraphResolveDepths" - }, - "query": {}, - "traversalPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/EntityTraversalPath" - } - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryOptions" + "$ref": "#/components/schemas/QueryEntitiesRequest" }, { "type": "object", "required": [ - "filter", "traversalPaths", "graphResolveDepths" ], "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - }, "graphResolveDepths": { "$ref": "#/components/schemas/GraphResolveDepths" }, @@ -8168,41 +8083,14 @@ { "allOf": [ { - "$ref": "#/components/schemas/EntityQueryOptions" - }, - { - "type": "object", - "required": [ - "query", - "traversalPaths" - ], - "properties": { - "query": {}, - "traversalPaths": { - "type": "array", - "items": { - "$ref": "#/components/schemas/TraversalPath" - } - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/components/schemas/EntityQueryOptions" + "$ref": "#/components/schemas/QueryEntitiesRequest" }, { "type": "object", "required": [ - "filter", "traversalPaths" ], "properties": { - "filter": { - "$ref": "#/components/schemas/Filter" - }, "traversalPaths": { "type": "array", "items": { From 32ecfb3b9e1f9da8b6546b2b9b44062b47c9f9f5 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 09:29:48 +0200 Subject: [PATCH 6/8] chore: fix capitalization --- libs/@local/graph/api/src/rest/entity/query/mod.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/libs/@local/graph/api/src/rest/entity/query/mod.rs b/libs/@local/graph/api/src/rest/entity/query/mod.rs index d788b3199e9..31e0b654121 100644 --- a/libs/@local/graph/api/src/rest/entity/query/mod.rs +++ b/libs/@local/graph/api/src/rest/entity/query/mod.rs @@ -15,7 +15,7 @@ use hash_graph_store::{ }; use hash_temporal_client::TemporalClient; use serde::Deserialize as _; -use serde_json::value::RawValue as RawJsonvalue; +use serde_json::value::RawValue as RawJsonValue; use type_system::{ knowledge::entity::id::EntityId, ontology::VersionedUrl, @@ -59,7 +59,7 @@ pub(super) async fn query_entities( temporal_client: Extension>>, Extension(api_config): Extension, mut query_logger: Option>, - Json(request): Json>, + Json(request): Json>, ) -> Result>, BoxedResponse> where S: StorePool + Send + Sync, From fbba50f588a099f7a4604421121174858d6fe6a1 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:48:10 +0200 Subject: [PATCH 7/8] feat: add conversion parameters that skip validation --- .../api/src/rest/entity/query/request.rs | 65 ++++++++++++++++--- .../manual_queries/entity_queries/mod.rs | 4 +- 2 files changed, 59 insertions(+), 10 deletions(-) diff --git a/libs/@local/graph/api/src/rest/entity/query/request.rs b/libs/@local/graph/api/src/rest/entity/query/request.rs index deb76c5d73a..dc256dd8f00 100644 --- a/libs/@local/graph/api/src/rest/entity/query/request.rs +++ b/libs/@local/graph/api/src/rest/entity/query/request.rs @@ -188,20 +188,21 @@ pub struct QueryEntitiesRequest<'q, 's, 'p> { } impl<'q, 'p> QueryEntitiesRequest<'q, '_, 'p> { - /// # Errors + /// Convert this request into [`QueryEntitiesParams`] with the given [`ApiConfig`] and resolved + /// limit. /// - /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in - /// [`ApiConfig::query_entity_limit`]. - pub fn into_params( + /// Does not validate that the resolved limit does not exceed [`ApiConfig::query_entity_limit`]. + pub fn into_params_unchecked( self, config: ApiConfig, - ) -> Result, Report> + limit: Option, + ) -> QueryEntitiesParams<'q> where 'p: 'q, { - let limit = resolve_limit(self.limit, config.query_entity_limit)?; + let limit = limit.or(self.limit).unwrap_or(config.query_entity_limit); - Ok(QueryEntitiesParams { + QueryEntitiesParams { filter: self.filter, sorting: EntityQuerySorting { paths: generate_sorting_paths(self.sorting_paths, &self.temporal_axes), @@ -219,7 +220,26 @@ impl<'q, 'p> QueryEntitiesRequest<'q, '_, 'p> { include_type_ids: self.include_type_ids, include_type_titles: self.include_type_titles, include_permissions: self.include_permissions, - }) + } + } + + /// Convert this request into [`QueryEntitiesParams`] with the given [`ApiConfig`] and resolved + /// limit. + /// + /// # Errors + /// + /// Returns [`LimitExceededError`] if the requested limit exceeds the configured maximum in + /// [`ApiConfig::query_entity_limit`]. + pub fn into_params( + self, + config: ApiConfig, + ) -> Result, Report> + where + 'p: 'q, + { + let limit = resolve_limit(self.limit, config.query_entity_limit)?; + + Ok(self.into_params_unchecked(config, Some(limit))) } } @@ -286,6 +306,35 @@ impl<'q, 's, 'p> QueryEntitySubgraphRequest<'q, 's, 'p> { } } + /// Convert the request into traversal parameters. Skipping validation. + #[must_use] + pub fn into_traversal_params_unchecked(self, config: ApiConfig) -> QueryEntitySubgraphParams<'q> + where + 'p: 'q, + { + let (request, params) = self.into_parts(); + let request = request.into_params_unchecked(config, None); + + match params { + SubgraphTraversalParams::Paths { traversal_paths } => { + QueryEntitySubgraphParams::Paths { + traversal_paths, + request, + } + } + SubgraphTraversalParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + } => QueryEntitySubgraphParams::ResolveDepths { + traversal_paths, + graph_resolve_depths, + request, + }, + } + } + + /// Convert the request into traversal parameters. + /// /// # Errors /// /// Returns [`QueryEntitySubgraphError`] if: diff --git a/tests/graph/benches/manual_queries/entity_queries/mod.rs b/tests/graph/benches/manual_queries/entity_queries/mod.rs index 092d4cd9ad5..460a4239f49 100644 --- a/tests/graph/benches/manual_queries/entity_queries/mod.rs +++ b/tests/graph/benches/manual_queries/entity_queries/mod.rs @@ -341,7 +341,7 @@ where request.actor_id, request .request - .into_params(config) + .into_params_unchecked(config, None) .expect("limit should not exceed configured maximum"), ) .await @@ -353,7 +353,7 @@ where request.actor_id, request .request - .into_traversal_params(config) + .into_traversal_params_unchecked(config) .expect("limit should not exceed configured maximum"), ) .await From 82d14fdd634467175a7196012ae0153975991546 Mon Sep 17 00:00:00 2001 From: Bilal Mahmoud <7252775+indietyp@users.noreply.github.com> Date: Tue, 9 Jun 2026 11:50:02 +0200 Subject: [PATCH 8/8] fix: compile --- .../graph/benches/manual_queries/entity_queries/mod.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/tests/graph/benches/manual_queries/entity_queries/mod.rs b/tests/graph/benches/manual_queries/entity_queries/mod.rs index 460a4239f49..448497f7762 100644 --- a/tests/graph/benches/manual_queries/entity_queries/mod.rs +++ b/tests/graph/benches/manual_queries/entity_queries/mod.rs @@ -339,10 +339,7 @@ where let _response = store .query_entities( request.actor_id, - request - .request - .into_params_unchecked(config, None) - .expect("limit should not exceed configured maximum"), + request.request.into_params_unchecked(config, None), ) .await .expect("failed to read entities from store"); @@ -351,10 +348,7 @@ where let _response = store .query_entity_subgraph( request.actor_id, - request - .request - .into_traversal_params_unchecked(config) - .expect("limit should not exceed configured maximum"), + request.request.into_traversal_params_unchecked(config), ) .await .expect("failed to read entity subgraph from store");