Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion apps/hash-graph/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ error-stack = { workspace = true }
harpc-codec = { workspace = true, features = ["json"] }
harpc-server = { workspace = true }
hash-codec = { workspace = true }
hash-graph-api = { workspace = true }
hash-graph-api = { workspace = true, features = ["clap"] }
hash-graph-authorization = { workspace = true }
hash-graph-postgres-store = { workspace = true, features = ["clap"] }
hash-graph-store = { workspace = true }
Expand Down
6 changes: 5 additions & 1 deletion apps/hash-graph/src/subcommand/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use harpc_codec::json::JsonCodec;
use harpc_server::Server;
use hash_codec::bytes::JsonLinesEncoder;
use hash_graph_api::{
rest::{QueryLogger, RestApiStore, RestRouterDependencies, rest_api_router},
rest::{ApiConfig, QueryLogger, RestApiStore, RestRouterDependencies, rest_api_router},
rpc::Dependencies,
};
use hash_graph_authorization::policies::store::{PolicyStore, PrincipalStore};
Expand Down Expand Up @@ -164,6 +164,9 @@ pub struct ServerConfig {
#[clap(long, env = "HASH_GRAPH_SKIP_FILTER_PROTECTION")]
pub skip_filter_protection: bool,

#[clap(flatten)]
pub api_config: ApiConfig,

/// Outputs the queries made to the graph to the specified file.
#[clap(long)]
pub log_queries: Option<PathBuf>,
Expand Down Expand Up @@ -340,6 +343,7 @@ where
domain_regex: DomainValidator::new(config.allowed_url_domain),
temporal_client,
query_logger,
api_config: config.api_config,
});
start_rest_server(router, config.http_address, lifecycle);

Expand Down
6 changes: 6 additions & 0 deletions libs/@local/graph/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,9 @@ hashql-hir = { workspace = true }
hashql-syntax-jexpr = { workspace = true }
type-system = { workspace = true, features = ["utoipa"] }

# Private third-party dependencies (optional)
clap = { workspace = true, optional = true, features = ["derive", "env"] }

# Private third-party dependencies
bytes = { workspace = true }
derive-where = { workspace = true }
Expand All @@ -75,6 +78,9 @@ tracing-opentelemetry = { workspace = true }
utoipa = { workspace = true }
uuid = { workspace = true }

[features]
clap = ["dep:clap"]

[lints]
workspace = true

Expand Down
27 changes: 15 additions & 12 deletions libs/@local/graph/api/src/rest/entity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,13 +75,15 @@ use type_system::{
};
use utoipa::{OpenApi, ToSchema};

use super::{InteractiveHeader, status::BoxedResponse};
pub use crate::rest::entity_query_request::{
EntityQuery, EntityQueryOptions, QueryEntitiesRequest, QueryEntitySubgraphRequest,
};
use crate::rest::{
AuthenticatedUserHeader, OpenApiQuery, QueryLogger, entity_query_request::CompilationOptions,
json::Json, status::report_to_response, utoipa_typedef::subgraph::Subgraph,
ApiConfig, AuthenticatedUserHeader, InteractiveHeader, OpenApiQuery, QueryLogger,
entity_query_request::CompilationOptions,
json::Json,
status::{BoxedResponse, report_to_response},
utoipa_typedef::subgraph::Subgraph,
};

#[derive(OpenApi)]
Expand Down Expand Up @@ -428,6 +430,7 @@ async fn query_entities<S>(
InteractiveHeader(interactive): InteractiveHeader,
store_pool: Extension<Arc<S>>,
temporal_client: Extension<Option<Arc<TemporalClient>>>,
Extension(api_config): Extension<ApiConfig>,
mut query_logger: Option<Extension<QueryLogger>>,
Json(request): Json<Box<RawJsonvalue>>,
) -> Result<Json<QueryEntitiesResponse<'static>>, BoxedResponse>
Expand All @@ -449,13 +452,6 @@ where

let (query, options) = request.into_parts();

if options.limit == Some(0) {
tracing::warn!(
%actor_id,
"The limit is set to zero, so no entities will be returned."
);
}

// TODO: https://linear.app/hash/issue/H-5351/reuse-parts-between-compilation-units
let mut heap = Heap::uninitialized();

Expand All @@ -469,7 +465,10 @@ where

let filter = query.compile(&heap, CompilationOptions { interactive })?;

let params = options.into_params(filter);
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)
Expand Down Expand Up @@ -545,6 +544,7 @@ async fn query_entity_subgraph<S>(
InteractiveHeader(interactive): InteractiveHeader,
store_pool: Extension<Arc<S>>,
temporal_client: Extension<Option<Arc<TemporalClient>>>,
Extension(api_config): Extension<ApiConfig>,
mut query_logger: Option<Extension<QueryLogger>>,
Json(request): Json<serde_json::Value>,
) -> Result<Json<QueryEntitySubgraphResponse<'static>>, BoxedResponse>
Expand Down Expand Up @@ -578,7 +578,10 @@ where

let filter = query.compile(&heap, CompilationOptions { interactive })?;

let params = options.into_traversal_params(filter, traversal);
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)
Expand Down
117 changes: 65 additions & 52 deletions libs/@local/graph/api/src/rest/entity_query_request.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ use axum::{
Json,
response::{Html, IntoResponse as _},
};
use error_stack::Report;
use hash_graph_store::{
entity::{
EntityQueryCursor, EntityQueryPath, EntityQuerySorting, EntityQuerySortingRecord,
Expand Down Expand Up @@ -65,44 +66,38 @@ use serde_json::value::RawValue as RawJsonValue;
use type_system::knowledge::Entity;
use utoipa::ToSchema;

use super::status::BoxedResponse;
use super::{ApiConfig, status::BoxedResponse};

#[tracing::instrument(level = "info", skip_all)]
fn generate_sorting_paths(
paths: Option<Vec<EntityQuerySortingRecord<'_>>>,
limit: Option<usize>,
cursor: Option<EntityQueryCursor<'_>>,
temporal_axes: &QueryTemporalAxesUnresolved,
) -> EntityQuerySorting<'static> {
) -> Vec<EntityQuerySortingRecord<'static>> {
let temporal_axes_sorting_path = match temporal_axes {
QueryTemporalAxesUnresolved::TransactionTime { .. } => &EntityQueryPath::TransactionTime,
QueryTemporalAxesUnresolved::DecisionTime { .. } => &EntityQueryPath::DecisionTime,
};

let sorting = paths
paths
.map_or_else(
|| {
if limit.is_some() || cursor.is_some() {
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,
},
]
} else {
Vec::new()
}
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;
Expand Down Expand Up @@ -150,12 +145,7 @@ fn generate_sorting_paths(
)
.into_iter()
.map(EntityQuerySortingRecord::into_owned)
.collect();

EntityQuerySorting {
paths: sorting,
cursor: cursor.map(EntityQueryCursor::into_owned),
}
.collect()
}

/// Internal deserialization proxy for `QueryEntitiesRequest`.
Expand Down Expand Up @@ -485,7 +475,7 @@ impl<'q> EntityQuery<'q> {
}

#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display)]
enum EntityQueryOptionsError {
pub enum EntityQueryOptionsError {
#[display(
"Field '{field}' is only valid in subgraph requests. Use the subgraph endpoint instead."
)]
Expand All @@ -495,6 +485,8 @@ enum EntityQueryOptionsError {
instead."
)]
InvalidFieldForEntityOptions { field: &'static str },
#[display("The requested limit ({requested}) exceeds the maximum allowed limit ({max}).")]
LimitExceeded { requested: usize, max: usize },
}

impl core::error::Error for EntityQueryOptionsError {}
Expand Down Expand Up @@ -597,20 +589,37 @@ impl<'q, 's, 'p> TryFrom<FlatQueryEntitiesRequestData<'q, 's, 'p>> for EntityQue
}

impl<'p> EntityQueryOptions<'_, 'p> {
#[must_use]
pub fn into_params<'f>(self, filter: Filter<'f, Entity>) -> QueryEntitiesParams<'f>
/// # Errors
///
/// Returns `LimitExceeded` 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<QueryEntitiesParams<'f>, Report<EntityQueryOptionsError>>
where
'p: 'f,
{
QueryEntitiesParams {
let max = config.query_entity_limit;
let limit = match self.limit {
Some(requested) if requested > max => {
return Err(Report::new(EntityQueryOptionsError::LimitExceeded {
requested,
max,
}));
}
Some(limit) => limit,
None => max,
};

Ok(QueryEntitiesParams {
filter,
sorting: generate_sorting_paths(
self.sorting_paths,
self.limit,
self.cursor,
&self.temporal_axes,
),
limit: self.limit,
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,
Expand All @@ -622,33 +631,37 @@ impl<'p> EntityQueryOptions<'_, 'p> {
include_type_ids: self.include_type_ids,
include_type_titles: self.include_type_titles,
include_permissions: self.include_permissions,
}
})
}

#[must_use]
/// # Errors
///
/// Returns `LimitExceeded` 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,
) -> QueryEntitySubgraphParams<'q>
config: ApiConfig,
) -> Result<QueryEntitySubgraphParams<'q>, Report<EntityQueryOptionsError>>
where
'p: 'q,
{
match traversal {
SubgraphTraversalParams::Paths { traversal_paths } => {
QueryEntitySubgraphParams::Paths {
Ok(QueryEntitySubgraphParams::Paths {
traversal_paths,
request: self.into_params(filter),
}
request: self.into_params(filter, config)?,
})
}
SubgraphTraversalParams::ResolveDepths {
traversal_paths,
graph_resolve_depths,
} => QueryEntitySubgraphParams::ResolveDepths {
} => Ok(QueryEntitySubgraphParams::ResolveDepths {
traversal_paths,
graph_resolve_depths,
request: self.into_params(filter),
},
request: self.into_params(filter, config)?,
}),
}
}
}
Expand Down
19 changes: 18 additions & 1 deletion libs/@local/graph/api/src/rest/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,21 @@ pub enum OpenApiQuery<'a> {
DiffEntity(&'a DiffEntityParams),
}

/// Server-side configuration for the REST API, shared across handlers via an [`Extension`].
#[derive(Debug, Clone, Copy)]
#[cfg_attr(feature = "clap", derive(clap::Parser))]
pub struct ApiConfig {
/// The default and maximum number of entities returned by a single query.
///
/// When a request omits `limit`, this value is used. Requests that specify a `limit` larger
/// than this value are rejected.
#[cfg_attr(
feature = "clap",
clap(long, default_value_t = 1000, env = "HASH_GRAPH_QUERY_ENTITY_LIMIT")
)]
pub query_entity_limit: usize,
}

pub struct RestRouterDependencies<S>
where
S: StorePool + Send + Sync + 'static,
Expand All @@ -331,6 +346,7 @@ where
pub temporal_client: Option<Arc<TemporalClient>>,
pub domain_regex: DomainValidator,
pub query_logger: Option<QueryLogger>,
pub api_config: ApiConfig,
}

/// A [`Router`] that only serves the `OpenAPI` specification (JSON, and necessary subschemas) for
Expand Down Expand Up @@ -376,7 +392,8 @@ where
.layer(http_tracing_layer::HttpTracingLayer)
.layer(Extension(dependencies.store))
.layer(Extension(dependencies.temporal_client))
.layer(Extension(dependencies.domain_regex));
.layer(Extension(dependencies.domain_regex))
.layer(Extension(dependencies.api_config));

if let Some(query_logger) = dependencies.query_logger {
router = router.layer(Extension(query_logger));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -700,9 +700,7 @@ where
(None, None, None, None, None, None)
};

if let Some(limit) = params.limit {
compiler.set_limit(limit);
}
compiler.set_limit(params.limit);

let cursor_parameters = params.sorting.encode().change_context(QueryError)?;
let cursor_indices = params
Expand Down Expand Up @@ -742,7 +740,7 @@ where
.enumerate()
.map(|(idx, row)| {
let row = TypedRow::<Entity, EntityQueryCursor>::from(row);
if idx == num_rows - 1 && params.limit == Some(num_rows) {
if idx == num_rows - 1 && params.limit == num_rows {
cursor = Some(row.decode_cursor(&artifacts));
}
row.decode_record(&artifacts)
Expand Down
Loading
Loading