Split object_types from models in JSONAPI catalog / schema (#1356)

<!-- The PR description should answer 2 important questions: -->

### What

Previously in JSONAPI a model held all it's field types. This approach
quickly falls apart with recursive types and when adding nested fields /
relationships.

### How

This outputs the object types as their own items in the catalog and
refers to them throughout rather than inlining the types everywhere.

V3_GIT_ORIGIN_REV_ID: 7f05bf964c6551c9aaf04ba6f2e02bf8f0ce272d
This commit is contained in:
Daniel Harvey 2024-11-18 10:52:41 +00:00 committed by hasura-bot
parent b8da04cf61
commit 851e3accc9
19 changed files with 3327 additions and 2721 deletions

1
v3/Cargo.lock generated
View File

@ -3065,6 +3065,7 @@ dependencies = [
"reqwest",
"serde",
"serde_json",
"thiserror",
"tokio",
"tracing-util",
]

View File

@ -67,10 +67,12 @@ async fn handle_rest_schema(
"Handle schema",
SpanVisibility::User,
|| match state.jsonapi_catalog.state_per_role.get(&session.role) {
Some(jsonapi_state) => {
let spec = jsonapi::openapi_schema(jsonapi_state);
JsonApiSchemaResponse { spec }
}
Some(jsonapi_state) => match jsonapi::openapi_schema(jsonapi_state) {
Ok(spec) => JsonApiSchemaResponse { spec },
Err(_) => JsonApiSchemaResponse {
spec: jsonapi::empty_schema(),
},
},
None => JsonApiSchemaResponse {
spec: jsonapi::empty_schema(),
},

View File

@ -22,6 +22,7 @@ ndc-models = { workspace = true }
oas3 = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies]
insta = { workspace = true }
@ -32,4 +33,4 @@ tokio = { workspace = true }
workspace = true
[package.metadata.cargo-machete]
ignored = ["error_chain", "log", "queryst", "serde", "serde_derive" ]
ignored = ["error_chain", "log", "queryst", "serde_derive" ]

View File

@ -1,150 +1,4 @@
use crate::types::{FieldType, ModelWarning};
use hasura_authn_core::Role;
use indexmap::IndexMap;
use metadata_resolve::{
ModelWithArgumentPresets, ObjectTypeWithRelationships, Qualified, QualifiedBaseType,
QualifiedTypeName, QualifiedTypeReference, ScalarTypeRepresentation,
};
use open_dds::{
data_connector::DataConnectorName,
types::{CustomTypeName, FieldName, InbuiltType},
};
use std::collections::{BTreeMap, BTreeSet};
// look at permissions and work out which fields we're allowed to see
// this is quite limited and leans to be overcautious
pub fn get_model_fields(
model: &ModelWithArgumentPresets,
role: &Role,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
scalar_types: &BTreeMap<Qualified<CustomTypeName>, ScalarTypeRepresentation>,
) -> Result<IndexMap<FieldName, FieldType>, ModelWarning> {
// if we have no select permission for the model, ignore it
if !model.select_permissions.contains_key(role) {
return Err(ModelWarning::NoSelectPermission);
}
let underlying_object_type = object_types.get(&model.model.data_type).ok_or_else(|| {
ModelWarning::NoObjectTypeFound {
object_type_name: model.model.data_type.clone(),
}
})?;
let model_source = model
.model
.source
.as_ref()
.ok_or_else(|| ModelWarning::NoModelSource)?;
// if we have no output permissions for the underlying object type, ignore it
let output_permissions_for_role = underlying_object_type
.type_output_permissions
.get(role)
.ok_or_else(|| ModelWarning::NoObjectTypePermission {
object_type_name: model.model.data_type.clone(),
})?;
let mut type_fields = IndexMap::new();
// otherwise return all fields
for (field_name, field_info) in
model
.model
.type_fields
.iter()
.filter(|(field_name, _field_info)| {
output_permissions_for_role
.allowed_fields
.contains(*field_name)
})
{
let field_type = field_type_from_type_representation(
&field_info.field_type,
scalar_types,
object_types,
&model_source.data_connector.name,
&mut BTreeSet::new(),
)?;
type_fields.insert(field_name.clone(), field_type);
}
Ok(type_fields)
}
// turn an OpenDD type into a type representation
fn field_type_from_type_representation(
qualified_type_reference: &QualifiedTypeReference,
scalar_types: &BTreeMap<Qualified<CustomTypeName>, ScalarTypeRepresentation>,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
data_connector_name: &Qualified<DataConnectorName>,
found_objects: &mut BTreeSet<Qualified<CustomTypeName>>,
) -> Result<FieldType, ModelWarning> {
// NOTE: currently we assume everything is nullable because a user might
// not include a field in sparse fields
match &qualified_type_reference.underlying_type {
QualifiedBaseType::Named(name) => match name {
QualifiedTypeName::Inbuilt(inbuilt) => {
Ok(FieldType::TypeRepresentation(match inbuilt {
InbuiltType::String | InbuiltType::ID => ndc_models::TypeRepresentation::String,
InbuiltType::Int => ndc_models::TypeRepresentation::Int64,
InbuiltType::Float => ndc_models::TypeRepresentation::Float64,
InbuiltType::Boolean => ndc_models::TypeRepresentation::Boolean,
}))
}
QualifiedTypeName::Custom(custom_type_name) => {
match scalar_types.get(custom_type_name) {
Some(scalar_type) => {
match scalar_type.representations.get(data_connector_name) {
Some(value_representation) => {
Ok(FieldType::TypeRepresentation(value_representation.clone()))
}
None => Err(ModelWarning::NoTypeRepresentationFoundForDataConnector {
data_connector_name: data_connector_name.clone(),
object_type_name: custom_type_name.clone(),
}),
}
}
None => match object_types.get(custom_type_name) {
Some(object_type) => {
// let's just disallow recursive types for now
if found_objects.contains(custom_type_name) {
return Err(ModelWarning::RecursiveTypeFound {
object_type_name: custom_type_name.clone(),
});
}
// store this type to check if it appears again
found_objects.insert(custom_type_name.clone());
let mut items = IndexMap::new();
for (field_name, field) in &object_type.object_type.fields {
items.insert(
field_name.clone(),
field_type_from_type_representation(
&field.field_type,
scalar_types,
object_types,
data_connector_name,
found_objects,
)?,
);
}
Ok(FieldType::Object(items))
}
None => Err(ModelWarning::NoTypeRepresentationFound {
object_type_name: custom_type_name.clone(),
}),
},
}
}
},
QualifiedBaseType::List(ty) => Ok(FieldType::List(Box::new(
field_type_from_type_representation(
ty,
scalar_types,
object_types,
data_connector_name,
found_objects,
)?,
))),
}
}
mod types;
pub use types::{Catalog, Model, ObjectType, State, Type};
mod models;
mod object_types;

View File

@ -0,0 +1,40 @@
use super::types::Model;
use crate::types::ModelWarning;
use hasura_authn_core::Role;
use metadata_resolve::{ModelWithArgumentPresets, ObjectTypeWithRelationships, Qualified};
use open_dds::types::CustomTypeName;
use std::collections::BTreeMap;
// look at permissions and work out which fields we're allowed to see
// this is quite limited and leans to be overcautious
pub fn build_model(
model: &ModelWithArgumentPresets,
role: &Role,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
) -> Result<Model, ModelWarning> {
// if we have no select permission for the model, ignore it
if !model.select_permissions.contains_key(role) {
return Err(ModelWarning::NoSelectPermission);
}
object_types
.get(&model.model.data_type)
.ok_or_else(|| ModelWarning::NoObjectTypeFound {
object_type_name: model.model.data_type.clone(),
})?;
let model_source = model
.model
.source
.as_ref()
.ok_or_else(|| ModelWarning::NoModelSource)?;
let data_connector_name = model_source.data_connector.name.clone();
Ok(Model {
name: model.model.name.clone(),
description: model.model.raw.description.clone(),
data_type: model.model.data_type.clone(),
data_connector_name,
filter_expression_type: model.filter_expression_type.clone(),
})
}

View File

@ -0,0 +1,95 @@
use super::types::{ObjectType, ScalarTypeForDataConnector, Type};
use crate::types::ObjectTypeWarning;
use hasura_authn_core::Role;
use indexmap::IndexMap;
use metadata_resolve::{
ObjectTypeWithRelationships, Qualified, QualifiedBaseType, QualifiedTypeName,
QualifiedTypeReference, ScalarTypeRepresentation,
};
use open_dds::types::{CustomTypeName, InbuiltType};
use std::collections::BTreeMap;
// look at permissions and work out which fields we're allowed to see
// this is quite limited and leans to be overcautious
pub fn build_object_type(
object_type: &ObjectTypeWithRelationships,
role: &Role,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
scalar_types: &BTreeMap<Qualified<CustomTypeName>, ScalarTypeRepresentation>,
) -> Result<ObjectType, ObjectTypeWarning> {
// if we have no output permissions for the underlying object type, ignore it
let output_permissions_for_role = object_type
.type_output_permissions
.get(role)
.ok_or_else(|| ObjectTypeWarning::NoObjectTypePermission {})?;
let mut type_fields = IndexMap::new();
// otherwise return all fields
for (field_name, field_info) in
object_type
.object_type
.fields
.iter()
.filter(|(field_name, _field_info)| {
output_permissions_for_role
.allowed_fields
.contains(*field_name)
})
{
let field_type =
type_from_type_representation(&field_info.field_type, scalar_types, object_types)?;
type_fields.insert(field_name.clone(), field_type);
}
Ok(ObjectType(type_fields))
}
// turn an OpenDD type into a type representation
fn type_from_type_representation(
qualified_type_reference: &QualifiedTypeReference,
scalar_types: &BTreeMap<Qualified<CustomTypeName>, ScalarTypeRepresentation>,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
) -> Result<Type, ObjectTypeWarning> {
// NOTE: currently we assume everything is nullable because a user might
// not include a field in sparse fields
match &qualified_type_reference.underlying_type {
QualifiedBaseType::Named(name) => match name {
QualifiedTypeName::Inbuilt(inbuilt) => Ok(Type::Scalar(match inbuilt {
InbuiltType::String | InbuiltType::ID => ndc_models::TypeRepresentation::String,
InbuiltType::Int => ndc_models::TypeRepresentation::Int64,
InbuiltType::Float => ndc_models::TypeRepresentation::Float64,
InbuiltType::Boolean => ndc_models::TypeRepresentation::Boolean,
})),
QualifiedTypeName::Custom(custom_type_name) => {
match scalar_types.get(custom_type_name) {
Some(scalar_type) => {
Ok(Type::ScalarForDataConnector(ScalarTypeForDataConnector {
type_representations: scalar_type
.representations
.values()
.cloned()
.collect(),
}))
}
None => {
if object_types.contains_key(custom_type_name) {
// return reference to said object
Ok(Type::Object(custom_type_name.clone()))
} else {
Err(ObjectTypeWarning::NestedObjectNotFound {
object_type_name: custom_type_name.clone(),
})
}
}
}
}
},
QualifiedBaseType::List(ty) => Ok(Type::List(Box::new(type_from_type_representation(
ty,
scalar_types,
object_types,
)?))),
}
}

View File

@ -0,0 +1,152 @@
use super::models::build_model;
use super::object_types::build_object_type;
use crate::types::{RoleWarning, Warning};
use hasura_authn_core::Role;
use indexmap::IndexMap;
use metadata_resolve::{
deserialize_qualified_btreemap, serialize_qualified_btreemap, ModelExpressionType, Qualified,
};
use open_dds::{
data_connector::DataConnectorName,
models::ModelName,
types::{CustomTypeName, FieldName},
};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Deserialize, Serialize)]
pub struct Catalog {
pub state_per_role: BTreeMap<Role, State>,
}
impl Catalog {
pub fn new(metadata: &metadata_resolve::Metadata) -> (Self, Vec<Warning>) {
let mut warnings = vec![];
let state_per_role = metadata
.roles
.iter()
.map(|role| {
let (state, role_warnings) = State::new(metadata, role);
warnings.extend(role_warnings.iter().map(|warning| Warning::Role {
role: role.clone(),
warning: warning.clone(),
}));
(role.clone(), state)
})
.collect();
(Self { state_per_role }, warnings)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct State {
pub routes: BTreeMap<String, Model>,
#[serde(
serialize_with = "serialize_qualified_btreemap",
deserialize_with = "deserialize_qualified_btreemap"
)]
pub object_types: BTreeMap<Qualified<CustomTypeName>, ObjectType>,
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ObjectType(pub IndexMap<FieldName, Type>);
impl State {
pub fn new(metadata: &metadata_resolve::Metadata, role: &Role) -> (Self, Vec<RoleWarning>) {
let mut warnings = vec![];
let routes = metadata
.models
.iter()
.filter_map(|(model_name, model)| {
match build_model(model, role, &metadata.object_types) {
Ok(jsonapi_model) => Some((
format!("/{}/{}", model_name.subgraph, model_name.name),
jsonapi_model,
)),
Err(warning) => {
warnings.push(RoleWarning::Model {
model_name: model_name.clone(),
warning,
});
None
}
}
})
.collect::<BTreeMap<_, _>>();
// current naive approach is to include all types that `role` has
// access to. we could minimise required metadata by traversing the required object types
// when building the models, and only including those.
let object_types = metadata
.object_types
.iter()
.filter_map(|(object_type_name, object_type)| {
match build_object_type(
object_type,
role,
&metadata.object_types,
&metadata.scalar_types,
) {
Ok(jsonapi_object_type) => {
Some((object_type_name.clone(), jsonapi_object_type))
}
Err(warning) => {
warnings.push(RoleWarning::ObjectType {
object_type_name: object_type_name.clone(),
warning,
});
None
}
}
})
.collect::<BTreeMap<_, _>>();
(
Self {
routes,
object_types,
},
warnings,
)
}
}
// we're making the assumption here of one object type that works across all data connectors.
// this is questionable - as the scalar types might be represented differently for each one
// we have a few routes out of this:
// a) we go against GraphQL implementation and dynamically create OpenAPI type names, so we can
// make a version of each type for each data connector
// b) we make the user name provided an explicit type per data connector that implements it (urgh)
// c) we smash them all together and represent these with union types in OpenAPI where the scalar
// representations differ between connectors
// d) we smash them all together but represent them as `JSON`, ie the most general type
//
// for now we'll try d) but we should check we're happy with this before general release
// of the feature
#[derive(Debug, Deserialize, Serialize)]
pub enum Type {
Scalar(ndc_models::TypeRepresentation),
ScalarForDataConnector(ScalarTypeForDataConnector),
List(Box<Type>),
Object(Qualified<CustomTypeName>),
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ScalarTypeForDataConnector {
pub type_representations: BTreeSet<ndc_models::TypeRepresentation>,
}
// only the parts of a Model we need to construct a JSONAPI
// we'll filter out fields a given role can't see
#[derive(Debug, Deserialize, Serialize)]
pub struct Model {
pub name: Qualified<ModelName>,
pub description: Option<String>,
pub data_type: Qualified<CustomTypeName>,
pub data_connector_name: Qualified<DataConnectorName>,
/// let's consider only making this work with `BooleanExpressionType`
/// to simplify implementation and nudge users to upgrade
pub filter_expression_type: Option<ModelExpressionType>,
}

View File

@ -2,7 +2,8 @@ use std::sync::Arc;
use super::parse;
use super::process_response;
use super::types::{Catalog, Model, QueryResult, RequestError, State};
use super::types::{QueryResult, RequestError};
use crate::catalog::{Catalog, Model, State};
use axum::http::{HeaderMap, Method, Uri};
use hasura_authn_core::Session;
use metadata_resolve::Metadata;
@ -35,7 +36,15 @@ pub async fn handler_internal<'metadata>(
"create_query_ir",
"Create query IR",
SpanVisibility::User,
|| parse::create_query_ir(model, &http_method, &uri, &query_string),
|| {
parse::create_query_ir(
model,
&state.object_types,
&http_method,
&uri,
&query_string,
)
},
)?;
// execute the query with the query-engine

View File

@ -7,10 +7,9 @@ mod schema;
mod types;
// explicit exports
pub use catalog::Catalog;
pub use handler::handler_internal;
pub use middleware::rest_request_tracing_middleware;
pub use parse::ParseError;
pub use schema::{empty_schema, openapi_schema};
pub use types::{
Catalog, FieldType, InternalError, Model, ModelInfo, QueryResult, RequestError, State,
};
pub use types::{InternalError, ModelInfo, QueryResult, RequestError};

View File

@ -1,4 +1,4 @@
use super::types::{Model, ModelInfo, RequestError};
use super::types::{ModelInfo, RequestError};
use axum::http::{Method, Uri};
use indexmap::IndexMap;
use open_dds::{
@ -9,6 +9,9 @@ use open_dds::{
};
use serde::{Deserialize, Serialize};
mod filter;
use crate::catalog::{Model, ObjectType};
use metadata_resolve::Qualified;
use std::collections::BTreeMap;
#[derive(Debug, derive_more::Display, Serialize, Deserialize)]
pub enum ParseError {
@ -17,10 +20,21 @@ pub enum ParseError {
InvalidModelName(String),
InvalidSubgraph(String),
PathLengthMustBeAtLeastTwo,
CannotFindObjectType(Qualified<CustomTypeName>),
}
fn get_object_type<'a>(
object_types: &'a BTreeMap<Qualified<CustomTypeName>, ObjectType>,
object_type_name: &Qualified<CustomTypeName>,
) -> Result<&'a ObjectType, ParseError> {
object_types
.get(object_type_name)
.ok_or_else(|| ParseError::CannotFindObjectType(object_type_name.clone()))
}
pub fn create_query_ir(
model: &Model,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectType>,
_http_method: &Method,
uri: &Uri,
query_string: &jsonapi_library::query::Query,
@ -33,11 +47,14 @@ pub fn create_query_ir(
relationship: _,
} = parse_url(uri).map_err(RequestError::ParseError)?;
validate_sparse_fields(model, query_string)?;
let object_type =
get_object_type(object_types, &model.data_type).map_err(RequestError::ParseError)?;
validate_sparse_fields(&model.data_type, object_type, query_string)?;
// create the selection fields; include all fields of the model output type
let mut selection = IndexMap::new();
for field_name in model.type_fields.keys() {
for field_name in object_type.0.keys() {
if include_field(query_string, field_name, &model.data_type.name) {
let field_name_ident = Identifier::new(field_name.as_str())
.map_err(|e| RequestError::BadRequest(e.into()))?;
@ -115,16 +132,17 @@ pub fn create_query_ir(
// check all fields in sparse fields are accessible, explode if not
// this will disallow relationship or nested fields
fn validate_sparse_fields(
model: &Model,
object_type_name: &Qualified<CustomTypeName>,
object_type: &ObjectType,
query_string: &jsonapi_library::query::Query,
) -> Result<(), RequestError> {
let type_name_string = model.data_type.name.to_string();
let type_name_string = object_type_name.name.to_string();
if let Some(fields) = &query_string.fields {
for (type_name, type_fields) in fields {
if *type_name == type_name_string {
for type_field in type_fields {
let string_fields: Vec<_> =
model.type_fields.keys().map(ToString::to_string).collect();
object_type.0.keys().map(ToString::to_string).collect();
if !string_fields.contains(type_field) {
return Err(RequestError::BadRequest(format!(

View File

@ -1,4 +1,4 @@
use crate::Model;
use crate::catalog::Model;
use indexmap::IndexMap;
use metadata_resolve::{ModelExpressionType, Qualified};
use open_dds::query::{BooleanExpression, ObjectFieldOperand, ObjectFieldTarget, Operand, Value};

View File

@ -1,19 +1,29 @@
use crate::{Model, State};
use crate::catalog::{Model, ObjectType, State};
use std::collections::BTreeMap;
mod output;
mod parameters;
mod shared;
use metadata_resolve::Qualified;
use open_dds::types::CustomTypeName;
use output::object_schema_for_object_type;
use shared::{
array_schema, bool_schema, enum_schema, float_schema, int_schema, json_schema, object_schema,
string_schema,
pretty_typename, string_schema,
};
#[derive(Debug, thiserror::Error, serde::Serialize, serde::Deserialize)]
pub enum SchemaError {
#[error("Object {0} not found")]
ObjectNotFound(Qualified<CustomTypeName>),
}
// JSONAPI specifies "application/vnd.api+json"
// we're going with the more universally supported application/json
static JSONAPI_MEDIA_TYPE: &str = "application/json";
fn get_response(model: &Model) -> oas3::spec::Response {
let schema = oas3::spec::ObjectOrReference::Object(output::jsonapi_document_schema(model));
fn get_response(model: &Model, object_type: &ObjectType) -> oas3::spec::Response {
let schema =
oas3::spec::ObjectOrReference::Object(output::jsonapi_document_schema(model, object_type));
let media_type = oas3::spec::MediaType {
encoding: BTreeMap::new(),
@ -33,18 +43,18 @@ fn get_response(model: &Model) -> oas3::spec::Response {
}
}
fn get_route_for_model(model: &Model) -> oas3::spec::Operation {
fn get_route_for_model(model: &Model, object_type: &ObjectType) -> oas3::spec::Operation {
let parameters = vec![
oas3::spec::ObjectOrReference::Object(parameters::page_limit_parameter()),
oas3::spec::ObjectOrReference::Object(parameters::page_offset_parameter()),
oas3::spec::ObjectOrReference::Object(parameters::fields_parameter(model)),
oas3::spec::ObjectOrReference::Object(parameters::ordering_parameter(model)),
oas3::spec::ObjectOrReference::Object(parameters::fields_parameter(model, object_type)),
oas3::spec::ObjectOrReference::Object(parameters::ordering_parameter(model, object_type)),
];
let mut responses = BTreeMap::new();
responses.insert(
"200".into(),
oas3::spec::ObjectOrReference::Object(get_response(model)),
oas3::spec::ObjectOrReference::Object(get_response(model, object_type)),
);
oas3::spec::Operation {
@ -87,7 +97,8 @@ pub fn empty_schema() -> oas3::Spec {
extensions: BTreeMap::new(),
}
}
pub fn openapi_schema(state: &State) -> oas3::Spec {
pub fn openapi_schema(state: &State) -> Result<oas3::Spec, SchemaError> {
let info = oas3::spec::Info {
title: "Hasura JSONAPI (alpha)".into(),
summary: None,
@ -100,9 +111,15 @@ pub fn openapi_schema(state: &State) -> oas3::Spec {
license: None,
extensions: BTreeMap::new(),
};
let mut paths = BTreeMap::new();
for (route_name, route) in &state.routes {
let get = get_route_for_model(route);
for (route_name, model) in &state.routes {
let object_type = state
.object_types
.get(&model.data_type)
.ok_or_else(|| SchemaError::ObjectNotFound(model.data_type.clone()))?;
let get = get_route_for_model(model, object_type);
let full_route_path = format!("/v1/rest{route_name}");
@ -125,15 +142,43 @@ pub fn openapi_schema(state: &State) -> oas3::Spec {
paths.insert(full_route_path, path_item);
}
oas3::Spec {
// we'll need to generate a named object for each object type that we can reference in our
// models etc
let components = oas3::spec::Components {
callbacks: BTreeMap::new(),
examples: BTreeMap::new(),
extensions: BTreeMap::new(),
headers: BTreeMap::new(),
links: BTreeMap::new(),
parameters: BTreeMap::new(),
path_items: BTreeMap::new(),
request_bodies: BTreeMap::new(),
responses: BTreeMap::new(),
schemas: state
.object_types
.iter()
.map(|(object_type_name, object_type)| {
(
pretty_typename(object_type_name),
oas3::spec::ObjectOrReference::Object(object_schema_for_object_type(
object_type,
)),
)
})
.collect(),
security_schemes: BTreeMap::new(),
};
Ok(oas3::Spec {
openapi: "3.1.0".into(),
info,
servers: vec![],
paths: Some(paths),
components: None,
components: Some(components),
tags: vec![],
webhooks: BTreeMap::new(),
external_docs: None,
extensions: BTreeMap::new(),
}
})
}

View File

@ -1,9 +1,9 @@
use super::shared::pretty_typename;
use crate::catalog::{Model, ObjectType, Type};
use crate::schema::{
array_schema, bool_schema, enum_schema, float_schema, int_schema, json_schema, object_schema,
string_schema,
};
use crate::{FieldType, Model};
use oas3::spec::{ObjectOrReference, ObjectSchema};
use std::collections::BTreeMap;
@ -20,25 +20,46 @@ use std::collections::BTreeMap;
// ]}%
// an OpenDD type represented in OpenAPI
fn type_schema(field_type: &FieldType) -> ObjectSchema {
match field_type {
FieldType::TypeRepresentation(type_representation) => {
from_type_representation(type_representation)
fn type_schema(ty: &Type) -> ObjectOrReference<ObjectSchema> {
match ty {
Type::ScalarForDataConnector(set_of_types) => {
// if there is only one, use it, otherwise, JSON
match set_of_types.type_representations.first() {
Some(ty) => {
if set_of_types.type_representations.len() == 1 {
ObjectOrReference::Object(from_type_representation(ty))
} else {
ObjectOrReference::Object(from_type_representation(
&ndc_models::TypeRepresentation::JSON,
))
}
}
None => ObjectOrReference::Object(from_type_representation(
&ndc_models::TypeRepresentation::JSON,
)),
}
}
FieldType::List(field_type) => {
let inner = type_schema(field_type);
array_schema(ObjectOrReference::Object(inner))
Type::Scalar(type_representation) => {
ObjectOrReference::Object(from_type_representation(type_representation))
}
FieldType::Object(fields) => object_schema(
fields
.iter()
.map(|(k, v)| (k.to_string(), ObjectOrReference::Object(type_schema(v))))
.collect(),
vec![],
),
Type::List(field_type) => ObjectOrReference::Object(array_schema(type_schema(field_type))),
Type::Object(object_type_name) => ObjectOrReference::Ref {
ref_path: pretty_typename(object_type_name),
},
}
}
// what we output for each type
pub fn object_schema_for_object_type(object_type: &ObjectType) -> ObjectSchema {
let mut fields = BTreeMap::new();
for (name, ty) in &object_type.0 {
fields.insert(name.to_string(), type_schema(ty));
}
let required = vec![]; // these are used as output types for the moment so everything is
// nullable
object_schema(fields, required)
}
#[allow(deprecated)]
fn from_type_representation(type_representation: &ndc_models::TypeRepresentation) -> ObjectSchema {
match type_representation {
@ -68,13 +89,10 @@ fn from_type_representation(type_representation: &ndc_models::TypeRepresentation
// a single 'data' item. we'll need to pass in types so we can
// have nice types for the `attributes` and `type`, for now let's make
// them stringy
fn jsonapi_data_schema(model: &Model) -> ObjectSchema {
fn jsonapi_data_schema(model: &Model, object_type: &ObjectType) -> ObjectSchema {
let mut attributes = BTreeMap::new();
for (field_name, field_type) in &model.type_fields {
attributes.insert(
field_name.to_string(),
ObjectOrReference::Object(type_schema(field_type)),
);
for (field_name, field_type) in &object_type.0 {
attributes.insert(field_name.to_string(), type_schema(field_type));
}
let mut properties = BTreeMap::new();
@ -83,7 +101,7 @@ fn jsonapi_data_schema(model: &Model) -> ObjectSchema {
properties.insert(
"_type".into(),
ObjectOrReference::Object(enum_schema(vec![model.pretty_typename()])),
ObjectOrReference::Object(enum_schema(vec![pretty_typename(&model.data_type)])),
);
properties.insert(
@ -98,13 +116,13 @@ fn jsonapi_data_schema(model: &Model) -> ObjectSchema {
}
// top level jsonapi document
pub fn jsonapi_document_schema(model: &Model) -> ObjectSchema {
pub fn jsonapi_document_schema(model: &Model, object_type: &ObjectType) -> ObjectSchema {
let mut properties = BTreeMap::new();
properties.insert(
"data".into(),
ObjectOrReference::Object(array_schema(ObjectOrReference::Object(
jsonapi_data_schema(model),
jsonapi_data_schema(model, object_type),
))),
);

View File

@ -1,5 +1,5 @@
use super::shared::{enum_schema, int_schema};
use crate::Model;
use crate::catalog::{Model, ObjectType};
use std::collections::BTreeMap;
use std::string::ToString;
@ -43,17 +43,17 @@ pub fn page_limit_parameter() -> oas3::spec::Parameter {
}
}
pub fn fields_parameter(model: &Model) -> oas3::spec::Parameter {
pub fn fields_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec::Parameter {
let schema = oas3::spec::ObjectOrReference::Object(oas3::spec::ObjectSchema {
items: Some(Box::new(oas3::spec::ObjectOrReference::Object(
enum_schema(model.type_fields.keys().map(ToString::to_string).collect()),
enum_schema(object_type.0.keys().map(ToString::to_string).collect()),
))),
..oas3::spec::ObjectSchema::default()
});
let mut example = String::new();
for (i, field) in model.type_fields.keys().enumerate() {
if i > 0 && i < model.type_fields.len() {
for (i, field) in object_type.0.keys().enumerate() {
if i > 0 && i < object_type.0.len() {
example.push(',');
}
example.push_str(&field.to_string());
@ -77,11 +77,11 @@ pub fn fields_parameter(model: &Model) -> oas3::spec::Parameter {
}
}
pub fn ordering_parameter(model: &Model) -> oas3::spec::Parameter {
pub fn ordering_parameter(model: &Model, object_type: &ObjectType) -> oas3::spec::Parameter {
// each field can be `thing` (sort ascending by 'thing') or `-thing` (sort descending by
// 'thing')
let mut sort_keys = Vec::new();
for type_field in model.type_fields.keys() {
for type_field in object_type.0.keys() {
sort_keys.push(format!("{type_field}"));
sort_keys.push(format!("-{type_field}"));
}

View File

@ -1,6 +1,12 @@
use metadata_resolve::Qualified;
use oas3::spec::{ObjectOrReference, ObjectSchema, SchemaType, SchemaTypeSet};
use open_dds::types::CustomTypeName;
use std::collections::BTreeMap;
pub fn pretty_typename(custom_type_name: &Qualified<CustomTypeName>) -> String {
format!("{}_{}", custom_type_name.subgraph, custom_type_name.name)
}
pub fn bool_schema() -> ObjectSchema {
ObjectSchema {
schema_type: Some(SchemaTypeSet::Single(SchemaType::Boolean)),

View File

@ -1,124 +1,9 @@
use crate::catalog::get_model_fields;
use crate::parse;
use hasura_authn_core::Role;
use indexmap::IndexMap;
use metadata_resolve::{
ModelExpressionType, ModelWithArgumentPresets, ObjectTypeWithRelationships, Qualified,
ScalarTypeRepresentation,
};
use open_dds::{
data_connector::DataConnectorName,
identifier::SubgraphName,
models::ModelName,
types::{CustomTypeName, FieldName},
};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use metadata_resolve::Qualified;
use open_dds::{identifier::SubgraphName, models::ModelName, types::CustomTypeName};
use tracing_util::{ErrorVisibility, TraceableError};
#[derive(Debug, Deserialize, Serialize)]
pub struct Catalog {
pub state_per_role: BTreeMap<Role, State>,
}
impl Catalog {
pub fn new(metadata: &metadata_resolve::Metadata) -> (Self, Vec<Warning>) {
let mut warnings = vec![];
let state_per_role = metadata
.roles
.iter()
.map(|role| {
let (state, role_warnings) = State::new(metadata, role);
warnings.extend(role_warnings.iter().map(|warning| Warning::Role {
role: role.clone(),
warning: warning.clone(),
}));
(role.clone(), state)
})
.collect();
(Self { state_per_role }, warnings)
}
}
#[derive(Debug, Deserialize, Serialize)]
pub struct State {
pub routes: BTreeMap<String, Model>,
}
impl State {
pub fn new(metadata: &metadata_resolve::Metadata, role: &Role) -> (Self, Vec<RoleWarning>) {
let mut warnings = vec![];
let routes = metadata
.models
.iter()
.filter_map(|(model_name, model)| {
match Model::new(model, role, &metadata.object_types, &metadata.scalar_types) {
Ok(jsonapi_model) => Some((
format!("/{}/{}", model_name.subgraph, model_name.name),
jsonapi_model,
)),
Err(warning) => {
warnings.push(RoleWarning::Model {
model_name: model_name.clone(),
warning,
});
None
}
}
})
.collect::<BTreeMap<_, _>>();
(Self { routes }, warnings)
}
}
// feel we're going to need to think about object types for nested stuff here too
#[derive(Debug, Deserialize, Serialize)]
pub enum FieldType {
TypeRepresentation(ndc_models::TypeRepresentation),
List(Box<FieldType>),
Object(IndexMap<FieldName, FieldType>),
}
// only the parts of a Model we need to construct a JSONAPI
// we'll filter out fields a given role can't see
#[derive(Debug, Deserialize, Serialize)]
pub struct Model {
pub name: Qualified<ModelName>,
pub description: Option<String>,
pub data_type: Qualified<CustomTypeName>,
pub type_fields: IndexMap<FieldName, FieldType>,
/// let's consider only making this work with `BooleanExpressionType`
/// to simplify implementation and nudge users to upgrade
pub filter_expression_type: Option<ModelExpressionType>,
}
impl Model {
pub fn new(
model: &ModelWithArgumentPresets,
role: &Role,
object_types: &BTreeMap<Qualified<CustomTypeName>, ObjectTypeWithRelationships>,
scalar_types: &BTreeMap<Qualified<CustomTypeName>, ScalarTypeRepresentation>,
) -> Result<Model, ModelWarning> {
let type_fields = get_model_fields(model, role, object_types, scalar_types)?;
Ok(Model {
name: model.model.name.clone(),
description: model.model.raw.description.clone(),
data_type: model.model.data_type.clone(),
type_fields,
filter_expression_type: model.filter_expression_type.clone(),
})
}
pub fn pretty_typename(&self) -> String {
format!("{}_{}", self.data_type.subgraph, self.data_type.name)
}
}
#[derive(Debug, Clone)]
pub enum Warning {
Role { role: Role, warning: RoleWarning },
@ -130,6 +15,20 @@ pub enum RoleWarning {
model_name: Qualified<ModelName>,
warning: ModelWarning,
},
ObjectType {
object_type_name: Qualified<CustomTypeName>,
warning: ObjectTypeWarning,
},
}
// if we exclude something, let's say why
#[derive(Debug, Clone)]
#[allow(clippy::enum_variant_names)]
pub enum ObjectTypeWarning {
NoObjectTypePermission {},
NestedObjectNotFound {
object_type_name: Qualified<CustomTypeName>,
},
}
// if we exclude something, let's say why
@ -140,20 +39,7 @@ pub enum ModelWarning {
NoObjectTypeFound {
object_type_name: Qualified<CustomTypeName>,
},
NoObjectTypePermission {
object_type_name: Qualified<CustomTypeName>,
},
RecursiveTypeFound {
object_type_name: Qualified<CustomTypeName>,
},
NoModelSource,
NoTypeRepresentationFound {
object_type_name: Qualified<CustomTypeName>,
},
NoTypeRepresentationFoundForDataConnector {
data_connector_name: Qualified<DataConnectorName>,
object_type_name: Qualified<CustomTypeName>,
},
}
#[derive(Debug, derive_more::Display)]

View File

@ -3,105 +3,211 @@ source: crates/jsonapi/tests/jsonapi_golden_tests.rs
expression: generated_openapi
---
{
"openapi": "3.1.0",
"info": {
"title": "Hasura JSONAPI (alpha)",
"description": "REST API generated to match the JSON:API spec: https://jsonapi.org",
"version": "0.1"
},
"paths": {
"/v1/rest/default/Album": {
"get": {
"summary": "Fetch Album values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
"Ok": {
"openapi": "3.1.0",
"info": {
"title": "Hasura JSONAPI (alpha)",
"description": "REST API generated to match the JSON:API spec: https://jsonapi.org",
"version": "0.1"
},
"paths": {
"/v1/rest/default/Album": {
"get": {
"summary": "Fetch Album values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
},
"example": "5"
},
"example": "5"
},
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
},
"example": "10"
},
"example": "10"
},
{
"name": "fields[Album]",
"in": "query",
"description": "Optional list of fields from Album to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"AlbumId",
"Title"
]
}
{
"name": "fields[Album]",
"in": "query",
"description": "Optional list of fields from Album to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"AlbumId",
"Title"
]
}
},
"example": "AlbumId,Title"
},
"example": "AlbumId,Title"
},
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Album to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"AlbumId",
"-AlbumId",
"Title",
"-Title"
]
}
},
"example": "AlbumId,-Title"
}
],
"responses": {
"200": {
"description": "Successful Album response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Album"
]
},
"attributes": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"Title": {
"type": "object"
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Album to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"AlbumId",
"-AlbumId",
"Title",
"-Title"
]
}
},
"example": "AlbumId,-Title"
}
],
"responses": {
"200": {
"description": "Successful Album response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Album"
]
},
"attributes": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"Title": {
"type": "object"
}
}
},
"id": {
"type": "string"
}
}
}
}
}
}
}
}
}
}
}
},
"/v1/rest/default/Articles": {
"get": {
"summary": "Fetch Article values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
},
"example": "5"
},
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
},
"example": "10"
},
{
"name": "fields[Article]",
"in": "query",
"description": "Optional list of fields from Articles to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"article_id",
"author_id"
]
}
},
"example": "article_id,author_id"
},
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Articles to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"article_id",
"-article_id",
"author_id",
"-author_id"
]
}
},
"example": "article_id,-author_id"
}
],
"responses": {
"200": {
"description": "Successful Articles response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Article"
]
},
"attributes": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"author_id": {
"type": "object"
}
}
},
"id": {
"type": "string"
}
},
"id": {
"type": "string"
}
}
}
@ -114,105 +220,55 @@ expression: generated_openapi
}
}
},
"/v1/rest/default/Articles": {
"get": {
"summary": "Fetch Article values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
"components": {
"schemas": {
"default_Album": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"example": "5"
},
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
},
"example": "10"
},
{
"name": "fields[Article]",
"in": "query",
"description": "Optional list of fields from Articles to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"article_id",
"author_id"
]
}
},
"example": "article_id,author_id"
},
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Articles to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"article_id",
"-article_id",
"author_id",
"-author_id"
]
}
},
"example": "article_id,-author_id"
"Title": {
"type": "object"
}
}
],
"responses": {
"200": {
"description": "Successful Articles response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Article"
]
},
"attributes": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"author_id": {
"type": "object"
}
}
},
"id": {
"type": "string"
}
}
}
}
}
}
}
},
"default_Article": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"author_id": {
"type": "object"
}
}
},
"default_commandArticle": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"author_id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
},
"default_commandAuthor": {
"type": "object",
"properties": {
"first_name": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_name": {
"type": "string"
}
}
}

View File

@ -3,105 +3,107 @@ source: crates/jsonapi/tests/jsonapi_golden_tests.rs
expression: generated_openapi
---
{
"openapi": "3.1.0",
"info": {
"title": "Hasura JSONAPI (alpha)",
"description": "REST API generated to match the JSON:API spec: https://jsonapi.org",
"version": "0.1"
},
"paths": {
"/v1/rest/default/Articles": {
"get": {
"summary": "Fetch Article values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
"Ok": {
"openapi": "3.1.0",
"info": {
"title": "Hasura JSONAPI (alpha)",
"description": "REST API generated to match the JSON:API spec: https://jsonapi.org",
"version": "0.1"
},
"paths": {
"/v1/rest/default/Articles": {
"get": {
"summary": "Fetch Article values",
"parameters": [
{
"name": "page[limit]",
"in": "query",
"description": "Optional limit for fetched items",
"schema": {
"type": "integer"
},
"example": "5"
},
"example": "5"
},
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
{
"name": "page[offset]",
"in": "query",
"description": "Optional offset for fetched items",
"schema": {
"type": "integer"
},
"example": "10"
},
"example": "10"
},
{
"name": "fields[Article]",
"in": "query",
"description": "Optional list of fields from Articles to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"article_id",
"title"
]
}
{
"name": "fields[Article]",
"in": "query",
"description": "Optional list of fields from Articles to include in response. If no fields are provided, all fields are returned",
"schema": {
"items": {
"enum": [
"article_id",
"title"
]
}
},
"example": "article_id,title"
},
"example": "article_id,title"
},
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Articles to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"article_id",
"-article_id",
"title",
"-title"
]
}
},
"example": "article_id,-title"
}
],
"responses": {
"200": {
"description": "Successful Articles response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Article"
]
},
"attributes": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"title": {
"type": "string"
{
"name": "sort",
"in": "query",
"description": "Optional list of fields from Articles to use in sorting response. 'field' will sort in ascending order, whilst '-field' will sort descending.",
"schema": {
"items": {
"enum": [
"article_id",
"-article_id",
"title",
"-title"
]
}
},
"example": "article_id,-title"
}
],
"responses": {
"200": {
"description": "Successful Articles response",
"content": {
"application/json": {
"schema": {
"type": "object",
"required": [
"data"
],
"properties": {
"data": {
"type": "array",
"items": {
"type": "object",
"required": [
"id",
"_type",
"attributes"
],
"properties": {
"_type": {
"enum": [
"default_Article"
]
},
"attributes": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
},
"id": {
"type": "string"
}
},
"id": {
"type": "string"
}
}
}
@ -113,6 +115,21 @@ expression: generated_openapi
}
}
}
},
"components": {
"schemas": {
"default_Article": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
}
}
}
}
}