Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Schema (type) level directive support with optional support of federation composeDirective #1308

Merged
merged 15 commits into from
Jun 29, 2023

Conversation

tadovas
Copy link
Contributor

@tadovas tadovas commented Jun 22, 2023

Currently async_graphql supports only "execution" type directives (which can be specified on query fields) and provided function is called during query execution.

There is another type of directives supported by grapqhl spec - type system directives, which simply acts like type level decorators http://spec.graphql.org/June2018/#TypeSystemDirectiveLocation

Although those type of directives doesn't (and shouldn't) alter the behaviour of library itself, but its a powerful tool to communicate additional metadata to schema introspectors and with @composeDirective support, it can be used in various ways to communicate meta type info to supergraph routers etc.

The alternative way would be reuse already available @tag directives, limitation being that the value of tag is just a string which lacks further syntax check etc.

This PR proposes new proc macro TypeDirective which is similar to currently implemented Directive and is also is applied to rust functions. The difference is that function is used to generate arguments for directive definition, and later can be called on graphql construct to generate actual directive "invocation". Example:

    #[TypeDirective(
        location = "fielddefinition",
        location = "object",
    )]
    fn testDirective(scope: String, input: u32, opt: Option<u64>) {}

will result in following defintion on generated SDL schema:

directive @testDirective(scope: String!, input: Int!, opt: Int) on FIELD_DEFINITION | OBJECT

and can be invoked on object or field level like this:

    struct Query;
    ...
    #[Object(
        directive = testDirective::apply("object type".to_string(), 3, None),
    )]
    impl Query {
        #[graphql(
        directive = testDirective::apply("object field".to_string(), 4, None),
        ]
        async fn value(&self) -> &'static str {
            "abc"
        }
    }

which will result in the following SDL fragment:

type Query @testDirective(scope: "object type", input: 3) {
	value: String! @testDirective(scope: "object field", input: 4) @noArgsDirective
}

This allows to a nice way to extend grapqhl schema with additional metadata information.
Another powerful feature which is defined by apollo federation v2.1 is @composeDirective, which essentially says that any directives which need to be preserved on supergraph composition need to be declared with @composeDirective.
In order to achieve that two additional things need to be done on TypeDirective level and on SDL export options:

// enable composeDirective support
SDLExportOptions::new().federation().compose_directive();

and on TypeDirective additional property is requred:

    #[TypeDirective(
        location = "fielddefinition",
        location = "object",
        composable = "https://custom.spec.dev/extension/v1.0"
    )]
    fn testDirective(scope: String, input: u32, opt: Option<u64>) {}

composable property needs to contain strict URL with at least two path segments (last is always semver style version like v1.0) or apollo federation supergraph checks will fail.
The addtions above will result in additional schema fragment, which basically exposes directive as composable and is preserved in supergraph construction:

extend schema @link(
	url: "https://custom.spec.dev/extension/v1.0"
	import: ["@testDirective"]
)
	@composeDirective(name: "@testDirective")

Different type directives can contain different compose extension urls - they will be grouped accordingly.

Limitations:
Currently this PR supporst only OBJECT and FIELD_DEFINITION locations, to keep the scope a bit smaller, but support for other grapqhl constructs can be added easily in the future.

Questions:

  • which name is better TypeDirective or SchemaDirective for macro?
  • it is possible to call function directly instead of directive::apply(...) style but since we generate a struct under the hood which we register as directive in that case we need a separate name for struct itself i.e:
#[TypeDirective]
fn something(...)

and invocation can be

directive=something(...)

But the struct can be named as somethingDirective and registered as schema.type_directive(somethingDirective). For me this might feel a little bit more natural, but generates two objects instead of one (currently apply function is a method inside struct).

@tadovas tadovas marked this pull request as ready for review June 23, 2023 10:25
@sunli829
Copy link
Collaborator

Thanks a lot for the PR 🙂

I have some suggestions for changes:

  1. Remove the Schema::type_directive method and only register it when the user actually uses a directive.
  2. Add constraints to TypeDirective to prevent it from being used in unsupported locations.
  3. Change the type of raw_directives from Vec<String> to Vec<MetaApplyTypeDirective>, maybe support more formatting options in the future.

Some code snippets

types defined in registry.rs

struct MetaApplyTypeDirective {
    name: String,
    args: IndexMap<String, Value>,
}

/// Field metadata
pub struct MetaField {
      ...        
     /// Custom directives applied
    pub type_directives: Vec<MetaApplyTypeDirective>,
}

the following two types are used to constrain the location where the instruction can be used. Using it in an inappropriate location will cause a compilation error.

trait TypeDirectiveObject {
    fn meta(self) -> MetaApplyTypeDirective;
}
trait TypeDirectiveFieldDefinition {
    fn meta(self) -> MetaApplyTypeDirective;
}

expanded TypeDirective macro

struct testDirectiveMeta(MetaApplyTypeDirective);

// implement TypeDirective for testDirective
// 
impl TypeDirective for testDirectiveMeta {
    ...
}

// testDirective only allow used on field definition
impl TypeDirectiveFieldDefinition for testDirectiveMeta  {
    fn meta(self) -> MetaApplyTypeDirective {
        self.0
    }
}

impl testDirective {
    fn apply(...) -> testDirectiveMeta;
}

use testDirective on the field

```rust
#[Object]
impl Query {
    #[graphql(
    directive = testDirective::apply("object field".to_string(), 4, None),
    ]
    async fn value(&self) -> &'static str {
        "abc"
    }
}
```

expanded Object macro

```rust
#crate_name::registry::MetaField {
    name: ::std::borrow::ToOwned::to_owned("value"),
    type_directives: vec![
        {
            let meta = testDirective::apply("object field".to_string(), 4, None);
            TypeDirective::register(&meta); // register the directive
            TypeDirectiveFieldDefinition::meta(meta) // get MetaApplyTypeDirective 
        }
    ],
    ...
}
```

@tadovas
Copy link
Contributor Author

tadovas commented Jun 26, 2023

I was thinking about auto registration on directive apply level, the only issue I see is that if we have two directives with the same name (non pub functions or directive itself renamed) and trying to register those will cause some confusion (we probably will need completely override previous registration), maybe we need additional check that directives are "equivalent" in the sense that they have the name and param names/types identical - otherwise throw compilation error.

@sunli829
Copy link
Collaborator

Maybe we can detect conflicts at runtime like this.

"`{}` and `{}` have the same GraphQL name `{}`",

@tadovas tadovas force-pushed the feature/compose_directive branch 2 times, most recently from 3e0bc08 to 4a6345c Compare June 26, 2023 13:25
@tadovas
Copy link
Contributor Author

tadovas commented Jun 26, 2023

  • Use MetaDirectiveInvocation instead of raw strings on Meta structures. I think this name is better because its more generic and it seems that in the future we can slowly adapt same invocations on other directives like tags too and reuse the same directive_invocations list.
  • Autoregister type directive on invocation.
  • Check for available locations on compile time

@tadovas
Copy link
Contributor Author

tadovas commented Jun 28, 2023

So for the last item - location check at compile time. This is the compilation error if you try to apply directive on Object level but its declared for field only:

    #[TypeDirective(
        location = "fielddefinition",
        composable = "https://custom.spec.dev/extension/v1.0"
    )]
    fn testDirective(scope: String, input: u32, opt: Option<u64>) {}

and then you try to apply it:

    #[derive(SimpleObject)]
    #[graphql(
        directive = testDirective::apply("simple object type".to_string(), 1, Some(3))
    )]
    struct SimpleValue {
        #[graphql(
            directive = testDirective::apply("field and param with \" symbol".to_string(), 2, Some(3))
        )]
        some_data: String,
    }

it fails with the compilation error:

error[E0277]: the trait bound `testDirective: Directive_At_OBJECT` is not satisfied
  --> tests/type_directive.rs:21:14
   |
21 |     #[derive(SimpleObject)]
   |              ^^^^^^^^^^^^ the trait `Directive_At_OBJECT` is not implemented for `testDirective`
   |
   = note: this error originates in the derive macro `SimpleObject` (in Nightly builds, run with -Z macro-backtrace for more info)

@tadovas tadovas marked this pull request as draft June 28, 2023 07:57
@tadovas
Copy link
Contributor Author

tadovas commented Jun 28, 2023

The only thing what bothers me is specification of locations on TypeDirective itself. I took definition of TypeDirectiveLocation from original DirectiveLocation and it has darling specification to expect strings to be in lowercase. But "fielddefinition" is not very readable. Maybe we should switch back to Pascal case? (like original Rust enum variants?). Other than this - I think we are good to go. Also we can appy strum crate for TypeDirectiveLocation itself to auto generate Display with expected strings instead of writting out all variants by hand.

@tadovas tadovas marked this pull request as ready for review June 28, 2023 13:00
@sunli829
Copy link
Collaborator

Good work, thank you! 👏

@sunli829 sunli829 merged commit a0ced1d into async-graphql:master Jun 29, 2023
7 of 8 checks passed
@tadovas
Copy link
Contributor Author

tadovas commented Jun 29, 2023

Ghrr probably another PR will follow to fix the checks :)

@sunli829
Copy link
Collaborator

I'm working on this 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

2 participants