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", "reqwest",
"serde", "serde",
"serde_json", "serde_json",
"thiserror",
"tokio", "tokio",
"tracing-util", "tracing-util",
] ]

View File

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

View File

@ -22,6 +22,7 @@ ndc-models = { workspace = true }
oas3 = { workspace = true } oas3 = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
thiserror = { workspace = true }
[dev-dependencies] [dev-dependencies]
insta = { workspace = true } insta = { workspace = true }
@ -32,4 +33,4 @@ tokio = { workspace = true }
workspace = true workspace = true
[package.metadata.cargo-machete] [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}; mod types;
use hasura_authn_core::Role; pub use types::{Catalog, Model, ObjectType, State, Type};
use indexmap::IndexMap; mod models;
use metadata_resolve::{ mod object_types;
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,
)?,
))),
}
}

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::parse;
use super::process_response; 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 axum::http::{HeaderMap, Method, Uri};
use hasura_authn_core::Session; use hasura_authn_core::Session;
use metadata_resolve::Metadata; use metadata_resolve::Metadata;
@ -35,7 +36,15 @@ pub async fn handler_internal<'metadata>(
"create_query_ir", "create_query_ir",
"Create query IR", "Create query IR",
SpanVisibility::User, 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 // execute the query with the query-engine

View File

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

View File

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

View File

@ -1,4 +1,4 @@
use crate::Model; use crate::catalog::Model;
use indexmap::IndexMap; use indexmap::IndexMap;
use metadata_resolve::{ModelExpressionType, Qualified}; use metadata_resolve::{ModelExpressionType, Qualified};
use open_dds::query::{BooleanExpression, ObjectFieldOperand, ObjectFieldTarget, Operand, Value}; 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; use std::collections::BTreeMap;
mod output; mod output;
mod parameters; mod parameters;
mod shared; mod shared;
use metadata_resolve::Qualified;
use open_dds::types::CustomTypeName;
use output::object_schema_for_object_type;
use shared::{ use shared::{
array_schema, bool_schema, enum_schema, float_schema, int_schema, json_schema, object_schema, 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" // JSONAPI specifies "application/vnd.api+json"
// we're going with the more universally supported application/json // we're going with the more universally supported application/json
static JSONAPI_MEDIA_TYPE: &str = "application/json"; static JSONAPI_MEDIA_TYPE: &str = "application/json";
fn get_response(model: &Model) -> oas3::spec::Response { fn get_response(model: &Model, object_type: &ObjectType) -> oas3::spec::Response {
let schema = oas3::spec::ObjectOrReference::Object(output::jsonapi_document_schema(model)); let schema =
oas3::spec::ObjectOrReference::Object(output::jsonapi_document_schema(model, object_type));
let media_type = oas3::spec::MediaType { let media_type = oas3::spec::MediaType {
encoding: BTreeMap::new(), 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![ let parameters = vec![
oas3::spec::ObjectOrReference::Object(parameters::page_limit_parameter()), oas3::spec::ObjectOrReference::Object(parameters::page_limit_parameter()),
oas3::spec::ObjectOrReference::Object(parameters::page_offset_parameter()), oas3::spec::ObjectOrReference::Object(parameters::page_offset_parameter()),
oas3::spec::ObjectOrReference::Object(parameters::fields_parameter(model)), oas3::spec::ObjectOrReference::Object(parameters::fields_parameter(model, object_type)),
oas3::spec::ObjectOrReference::Object(parameters::ordering_parameter(model)), oas3::spec::ObjectOrReference::Object(parameters::ordering_parameter(model, object_type)),
]; ];
let mut responses = BTreeMap::new(); let mut responses = BTreeMap::new();
responses.insert( responses.insert(
"200".into(), "200".into(),
oas3::spec::ObjectOrReference::Object(get_response(model)), oas3::spec::ObjectOrReference::Object(get_response(model, object_type)),
); );
oas3::spec::Operation { oas3::spec::Operation {
@ -87,7 +97,8 @@ pub fn empty_schema() -> oas3::Spec {
extensions: BTreeMap::new(), 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 { let info = oas3::spec::Info {
title: "Hasura JSONAPI (alpha)".into(), title: "Hasura JSONAPI (alpha)".into(),
summary: None, summary: None,
@ -100,9 +111,15 @@ pub fn openapi_schema(state: &State) -> oas3::Spec {
license: None, license: None,
extensions: BTreeMap::new(), extensions: BTreeMap::new(),
}; };
let mut paths = BTreeMap::new(); let mut paths = BTreeMap::new();
for (route_name, route) in &state.routes { for (route_name, model) in &state.routes {
let get = get_route_for_model(route); 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}"); 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); 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(), openapi: "3.1.0".into(),
info, info,
servers: vec![], servers: vec![],
paths: Some(paths), paths: Some(paths),
components: None, components: Some(components),
tags: vec![], tags: vec![],
webhooks: BTreeMap::new(), webhooks: BTreeMap::new(),
external_docs: None, external_docs: None,
extensions: BTreeMap::new(), extensions: BTreeMap::new(),
} })
} }

View File

@ -1,9 +1,9 @@
use super::shared::pretty_typename;
use crate::catalog::{Model, ObjectType, Type};
use crate::schema::{ use crate::schema::{
array_schema, bool_schema, enum_schema, float_schema, int_schema, json_schema, object_schema, array_schema, bool_schema, enum_schema, float_schema, int_schema, json_schema, object_schema,
string_schema, string_schema,
}; };
use crate::{FieldType, Model};
use oas3::spec::{ObjectOrReference, ObjectSchema}; use oas3::spec::{ObjectOrReference, ObjectSchema};
use std::collections::BTreeMap; use std::collections::BTreeMap;
@ -20,23 +20,44 @@ use std::collections::BTreeMap;
// ]}% // ]}%
// an OpenDD type represented in OpenAPI // an OpenDD type represented in OpenAPI
fn type_schema(field_type: &FieldType) -> ObjectSchema { fn type_schema(ty: &Type) -> ObjectOrReference<ObjectSchema> {
match field_type { match ty {
FieldType::TypeRepresentation(type_representation) => { Type::ScalarForDataConnector(set_of_types) => {
from_type_representation(type_representation) // 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,
))
} }
FieldType::List(field_type) => {
let inner = type_schema(field_type);
array_schema(ObjectOrReference::Object(inner))
} }
FieldType::Object(fields) => object_schema( None => ObjectOrReference::Object(from_type_representation(
fields &ndc_models::TypeRepresentation::JSON,
.iter() )),
.map(|(k, v)| (k.to_string(), ObjectOrReference::Object(type_schema(v))))
.collect(),
vec![],
),
} }
}
Type::Scalar(type_representation) => {
ObjectOrReference::Object(from_type_representation(type_representation))
}
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)] #[allow(deprecated)]
@ -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 // 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 // have nice types for the `attributes` and `type`, for now let's make
// them stringy // them stringy
fn jsonapi_data_schema(model: &Model) -> ObjectSchema { fn jsonapi_data_schema(model: &Model, object_type: &ObjectType) -> ObjectSchema {
let mut attributes = BTreeMap::new(); let mut attributes = BTreeMap::new();
for (field_name, field_type) in &model.type_fields { for (field_name, field_type) in &object_type.0 {
attributes.insert( attributes.insert(field_name.to_string(), type_schema(field_type));
field_name.to_string(),
ObjectOrReference::Object(type_schema(field_type)),
);
} }
let mut properties = BTreeMap::new(); let mut properties = BTreeMap::new();
@ -83,7 +101,7 @@ fn jsonapi_data_schema(model: &Model) -> ObjectSchema {
properties.insert( properties.insert(
"_type".into(), "_type".into(),
ObjectOrReference::Object(enum_schema(vec![model.pretty_typename()])), ObjectOrReference::Object(enum_schema(vec![pretty_typename(&model.data_type)])),
); );
properties.insert( properties.insert(
@ -98,13 +116,13 @@ fn jsonapi_data_schema(model: &Model) -> ObjectSchema {
} }
// top level jsonapi document // 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(); let mut properties = BTreeMap::new();
properties.insert( properties.insert(
"data".into(), "data".into(),
ObjectOrReference::Object(array_schema(ObjectOrReference::Object( 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 super::shared::{enum_schema, int_schema};
use crate::Model; use crate::catalog::{Model, ObjectType};
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::string::ToString; 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 { let schema = oas3::spec::ObjectOrReference::Object(oas3::spec::ObjectSchema {
items: Some(Box::new(oas3::spec::ObjectOrReference::Object( 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() ..oas3::spec::ObjectSchema::default()
}); });
let mut example = String::new(); let mut example = String::new();
for (i, field) in model.type_fields.keys().enumerate() { for (i, field) in object_type.0.keys().enumerate() {
if i > 0 && i < model.type_fields.len() { if i > 0 && i < object_type.0.len() {
example.push(','); example.push(',');
} }
example.push_str(&field.to_string()); 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 // each field can be `thing` (sort ascending by 'thing') or `-thing` (sort descending by
// 'thing') // 'thing')
let mut sort_keys = Vec::new(); 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}"));
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 oas3::spec::{ObjectOrReference, ObjectSchema, SchemaType, SchemaTypeSet};
use open_dds::types::CustomTypeName;
use std::collections::BTreeMap; 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 { pub fn bool_schema() -> ObjectSchema {
ObjectSchema { ObjectSchema {
schema_type: Some(SchemaTypeSet::Single(SchemaType::Boolean)), schema_type: Some(SchemaTypeSet::Single(SchemaType::Boolean)),

View File

@ -1,124 +1,9 @@
use crate::catalog::get_model_fields;
use crate::parse; use crate::parse;
use hasura_authn_core::Role; use hasura_authn_core::Role;
use indexmap::IndexMap; use metadata_resolve::Qualified;
use metadata_resolve::{ use open_dds::{identifier::SubgraphName, models::ModelName, types::CustomTypeName};
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 tracing_util::{ErrorVisibility, TraceableError}; 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)] #[derive(Debug, Clone)]
pub enum Warning { pub enum Warning {
Role { role: Role, warning: RoleWarning }, Role { role: Role, warning: RoleWarning },
@ -130,6 +15,20 @@ pub enum RoleWarning {
model_name: Qualified<ModelName>, model_name: Qualified<ModelName>,
warning: ModelWarning, 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 // if we exclude something, let's say why
@ -140,20 +39,7 @@ pub enum ModelWarning {
NoObjectTypeFound { NoObjectTypeFound {
object_type_name: Qualified<CustomTypeName>, object_type_name: Qualified<CustomTypeName>,
}, },
NoObjectTypePermission {
object_type_name: Qualified<CustomTypeName>,
},
RecursiveTypeFound {
object_type_name: Qualified<CustomTypeName>,
},
NoModelSource, NoModelSource,
NoTypeRepresentationFound {
object_type_name: Qualified<CustomTypeName>,
},
NoTypeRepresentationFoundForDataConnector {
data_connector_name: Qualified<DataConnectorName>,
object_type_name: Qualified<CustomTypeName>,
},
} }
#[derive(Debug, derive_more::Display)] #[derive(Debug, derive_more::Display)]

View File

@ -3,6 +3,7 @@ source: crates/jsonapi/tests/jsonapi_golden_tests.rs
expression: generated_openapi expression: generated_openapi
--- ---
{ {
"Ok": {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "Hasura JSONAPI (alpha)", "title": "Hasura JSONAPI (alpha)",
@ -1722,21 +1723,7 @@ expression: generated_openapi
"type": "integer" "type": "integer"
}, },
"location": { "location": {
"type": "object", "$ref": "default_location"
"properties": {
"campuses": {
"type": "array",
"items": {
"type": "string"
}
},
"city": {
"type": "string"
},
"country": {
"type": "string"
}
}
}, },
"name": { "name": {
"type": "string" "type": "string"
@ -1744,21 +1731,7 @@ expression: generated_openapi
"staff": { "staff": {
"type": "array", "type": "array",
"items": { "items": {
"type": "object", "$ref": "default_staff_member"
"properties": {
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"specialities": {
"type": "array",
"items": {
"type": "string"
}
}
}
} }
} }
} }
@ -2161,5 +2134,439 @@ expression: generated_openapi
} }
} }
} }
},
"components": {
"schemas": {
"default_Album": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"ArtistId": {
"type": "object"
},
"Title": {
"type": "object"
}
}
},
"default_Article": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"author_id": {
"type": "object"
},
"title": {
"type": "string"
}
}
},
"default_Artist": {
"type": "object",
"properties": {
"ArtistId": {
"type": "object"
},
"Name": {
"type": "object"
}
}
},
"default_Author": {
"type": "object",
"properties": {
"author_id": {
"type": "object"
},
"first_name": {
"type": "string"
}
}
},
"default_Customer": {
"type": "object",
"properties": {
"Address": {
"type": "object"
},
"City": {
"type": "object"
},
"Company": {
"type": "object"
},
"Country": {
"type": "object"
},
"CustomerId": {
"type": "object"
},
"Email": {
"type": "object"
},
"Fax": {
"type": "object"
},
"FirstName": {
"type": "object"
},
"LastName": {
"type": "object"
},
"Phone": {
"type": "object"
},
"PostalCode": {
"type": "object"
},
"State": {
"type": "object"
},
"SupportRepId": {
"type": "object"
}
}
},
"default_Employee": {
"type": "object",
"properties": {
"Address": {
"type": "object"
},
"BirthDate": {
"type": "object"
},
"City": {
"type": "object"
},
"Country": {
"type": "object"
},
"Email": {
"type": "object"
},
"EmployeeId": {
"type": "object"
},
"Fax": {
"type": "object"
},
"FirstName": {
"type": "object"
},
"HireDate": {
"type": "object"
},
"LastName": {
"type": "object"
},
"Phone": {
"type": "object"
},
"PostalCode": {
"type": "object"
},
"ReportsTo": {
"type": "object"
},
"State": {
"type": "object"
},
"Title": {
"type": "object"
}
}
},
"default_Genre": {
"type": "object",
"properties": {
"GenreId": {
"type": "object"
},
"Name": {
"type": "object"
}
}
},
"default_Invoice": {
"type": "object",
"properties": {
"BillingAddress": {
"type": "object"
},
"BillingCity": {
"type": "object"
},
"BillingCountry": {
"type": "object"
},
"BillingPostalCode": {
"type": "object"
},
"BillingState": {
"type": "object"
},
"CustomerId": {
"type": "object"
},
"InvoiceDate": {
"type": "object"
},
"InvoiceId": {
"type": "object"
},
"Total": {
"type": "object"
}
}
},
"default_InvoiceLine": {
"type": "object",
"properties": {
"InvoiceId": {
"type": "object"
},
"InvoiceLineId": {
"type": "object"
},
"Quantity": {
"type": "object"
},
"TrackId": {
"type": "object"
},
"UnitPrice": {
"type": "object"
}
}
},
"default_MediaType": {
"type": "object",
"properties": {
"MediaTypeId": {
"type": "object"
},
"Name": {
"type": "object"
}
}
},
"default_Playlist": {
"type": "object",
"properties": {
"Name": {
"type": "object"
},
"PlaylistId": {
"type": "object"
}
}
},
"default_PlaylistTrack": {
"type": "object",
"properties": {
"PlaylistId": {
"type": "object"
},
"TrackId": {
"type": "object"
}
}
},
"default_Track": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"Bytes": {
"type": "object"
},
"Composer": {
"type": "object"
},
"GenreId": {
"type": "object"
},
"MediaTypeId": {
"type": "object"
},
"Milliseconds": {
"type": "object"
},
"Name": {
"type": "object"
},
"TrackId": {
"type": "object"
},
"UnitPrice": {
"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"
}
}
},
"default_institution": {
"type": "object",
"properties": {
"departments": {
"type": "array",
"items": {
"type": "string"
}
},
"id": {
"type": "integer"
},
"location": {
"$ref": "default_location"
},
"name": {
"type": "string"
},
"staff": {
"type": "array",
"items": {
"$ref": "default_staff_member"
}
}
}
},
"default_location": {
"type": "object",
"properties": {
"campuses": {
"type": "array",
"items": {
"type": "string"
}
},
"city": {
"type": "string"
},
"country": {
"type": "string"
}
}
},
"default_spatial_ref_sys": {
"type": "object",
"properties": {
"auth_name": {
"type": "object"
},
"auth_srid": {
"type": "object"
},
"proj4text": {
"type": "object"
},
"srid": {
"type": "object"
},
"srtext": {
"type": "object"
}
}
},
"default_staff_member": {
"type": "object",
"properties": {
"first_name": {
"type": "string"
},
"last_name": {
"type": "string"
},
"specialities": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"default_topology_layer": {
"type": "object",
"properties": {
"child_id": {
"type": "object"
},
"feature_column": {
"type": "object"
},
"feature_type": {
"type": "object"
},
"layer_id": {
"type": "object"
},
"level": {
"type": "object"
},
"schema_name": {
"type": "object"
},
"table_name": {
"type": "object"
},
"topology_id": {
"type": "object"
}
}
},
"default_topology_topology": {
"type": "object",
"properties": {
"hasz": {
"type": "object"
},
"id": {
"type": "object"
},
"name": {
"type": "object"
},
"precision": {
"type": "object"
},
"srid": {
"type": "object"
}
}
}
}
}
} }
} }

View File

@ -3,6 +3,7 @@ source: crates/jsonapi/tests/jsonapi_golden_tests.rs
expression: generated_openapi expression: generated_openapi
--- ---
{ {
"Ok": {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "Hasura JSONAPI (alpha)", "title": "Hasura JSONAPI (alpha)",
@ -218,5 +219,60 @@ expression: generated_openapi
} }
} }
} }
},
"components": {
"schemas": {
"default_Album": {
"type": "object",
"properties": {
"AlbumId": {
"type": "object"
},
"Title": {
"type": "object"
}
}
},
"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,6 +3,7 @@ source: crates/jsonapi/tests/jsonapi_golden_tests.rs
expression: generated_openapi expression: generated_openapi
--- ---
{ {
"Ok": {
"openapi": "3.1.0", "openapi": "3.1.0",
"info": { "info": {
"title": "Hasura JSONAPI (alpha)", "title": "Hasura JSONAPI (alpha)",
@ -114,5 +115,21 @@ expression: generated_openapi
} }
} }
} }
},
"components": {
"schemas": {
"default_Article": {
"type": "object",
"properties": {
"article_id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
}
}
}
} }
} }