fsprojects / FSharp.Data.GraphQL

FSharp implementation of Facebook GraphQL query language.
http://fsprojects.github.io/FSharp.Data.GraphQL/
MIT License
395 stars 72 forks source link

Server architecture for large schemas. Type extensions? #290

Open agu-z opened 4 years ago

agu-z commented 4 years ago

My team is migrating an existing GraphQL API from JS to F#. Our API has a lot of types, so we decided to create a file per type (Define.Object).

The file structure looks like this:

If Organization has a users field that returns ListOf User, you can simply compile User.fs before Organization.fs. However, this doesn't work if User also needs to have an organizations field, because F# doesn't allow mutual recursion across files.

What is the recommended way to architecture a big server like this? The samples use a single schema file, but unfortunately, that's just not feasible for our use case. If we did this, we would eventually have a file with 20K+ lines of code (judging by the size of our JS API).

A solution might be supporting Type Extensions. Especially, Object Extensions: http://spec.graphql.org/June2018/#sec-Object-Extensions

With Object Extensions, we would be able to define these relationship fields at the return type module instead. In my example, Organization.fs would add the organizations field to the User type, and User.fs would add the users field to the Organization type.

This architecture would work out pretty nicely for us because the querying and authorization logic needed to return records of a type would remain in its module as opposed to being spread across the codebase.

Supporting GraphQL extensions would also allow users of this library to build GraphQL microservices, that could be composed using tools like Apollo Federation.

If you think that's a good idea, I'd be more than happy to contribute. Thanks!

johnberzy-bazinga commented 4 years ago

@agu-z My apologies for the late reply. As you alluded to we don't have a good solution for large schemas at present. We would be more than happy to take a contribution. Do you have a bit of time to sketch out what the API would look like for type extensions?

Presumably, The User and Organization records would be defined at top level e.g. Domain.fs and only the GraphQL Schema related code resides in Organization.fs and User.fs (in order to make the extensions typed). Am I getting that right?

Thanks, John

agu-z commented 4 years ago

Hey @johnberzy-bazinga, don't apologize! Thank you for getting back to me :)

Type-safety is one of the things we appreciate the most about this library, and I definitely would like to preserve that. So yes, I think you'd have to declare the records beforehand. In our case, this is already the case since we keep our persistence code separate. Besides records, you'd also have to declare all the GraphQL objects so you can reference them later. You wouldn't specify their fields yet, but I think this would be fine because AFAIK there's no way to "reference" an Object field from the outside.

The following is how I imagine it (in order of compilation):

// Persistence Models
...

// Types.fs

let Organization = Define.Object<OrganizationModel>("Organization")
let User = Define.Object<UserModel>("User")

// User.fs

let extensions = [
    Extend.Object(Types.User, [
      Define.Field("id", ID, fun _ user -> user.Id)
    ])

    Extend.Object(Types.Organization, [
      Define.AsyncField("users", ListOf Types.User, fun _ org -> usersByOrgId org.Id)
    ])
]

let queries = [
    Define.AsyncField("me", Types.User, fun ctx _ -> userById ctx.Metadata.["userId"])
]

// Organization.fs

let extensions = [
    Extend.Object(Types.Organization, [
      Define.Field("id", ID, fun _ org -> org.Id)
    ])

    Extend.Object(Types.User, [
      Define.AsyncField("organizations", ListOf Types.Organization, fun _ user -> organizationsByUserId user.Id)
    ])
]

let queries = [
   Define.AsyncField("organization", Types.Organization, [ Define.Input("id", ID) ], fun ctx _ -> organizationById <| ctx.Arg("id"))
   Define.AsyncField("organizations", ListOf Types.Organization, fun _ _ -> allOrganizations)
]

// Root.fs

let QueryRoot = Define.Object("Query", User.queries @ Organization.queries)

let schema = Schema(QueryRoot, extensions = User.extensions @ Organization.extensions) // reconcile here

let executor = Executor(schema)

This approach preserves the type-safety both for the existence of the object and the internal representation ('Val). Furthermore, each Type module does not alter or silently mutate other types but merely exposes available extensions, much like F#'s own Optional Type Extensions.

Besides Object, the Extend namespace could also contain other extensible types like Enum, or Interface. Object seems like the most useful one, though.

I do not want to impose a change on the style or vision you have for this library, so please let me know your honest opinion about this API. It's also possible that I'm not considering a use-case outside ours.

I appreciate all the effort put into this library, and I can't wait to implement whatever we come up with!

johnberzy-bazinga commented 4 years ago

This seems like a sound idea to me.

Wondering how extensions are represented in the json introspection schema. Do we merge the fields in while generating the schema, or is that up to the client to handle? Is it's up to the client I guess we'd also need to make sure the Type Provider understands extensions.

@Horusiath @ivelten thoughts?

johnberzy-bazinga commented 4 years ago

related: https://github.com/fsprojects/FSharp.Data.GraphQL/pull/249

agu-z commented 4 years ago

Oh, that PR seems helpful! Is there something holding it off?

I could not find a mention of Type Extensions in the Introspection part of the GraphQL spec, so I think the server should merge object fields and other type members.

I did some light experimentation with another GraphQL library, and it looks like that is indeed how it works. The introspection Object contains all its fields, regardless of whether they are part of the original Object declaration or defined in an extension. So this seems to be just a server-side concept.

johnberzy-bazinga commented 4 years ago

@agu-z since that PR is a breaking change, we were hoping to release it along with other changes in a 2.0 release. The other changes (primarily work on refactoring execution to support batch resolvers) have since stalled due to other projects. If we get extensions in we won't delay the release for batch resolvers.

I'm glad the introspection schema gets merged. An Extensions API like you sketched out would be a great addition to the library. Let me know if you require any assistance to get started.

jberzy commented 4 years ago

@agu-z,

Another option is we could always allow for forward declarations of a typedef. e.g.:

// User.fs

let OrganizationRef = Define.ObjectRef<OrganizationModel>("Organization")
let User = Define.Object(...
     Define.AsyncField("organizations", ListOf OrganizationRef, fun _ user -> organizationsByUserId user.Id)
)

// Organization.fs

let UserRef = Define.ObjectRef<UserModel>("User")
let Organization = Define.Object(...
     Define.AsyncField("users", ListOf UserRef, fun _ organization -> usersByOrganizationId organization.Id)
)

The primary benefit of this approach over extensions is it doesn't require the typedefs be split across multiple files.

At least one drawback is more "orphaned" typedefs not reachable from entrypoints. e.g. in the above scenario if UserRef and not User is ever "seen" by the schema we wouldn't have a handle to the underlying typedef to swap. The solution is to register User to the schema through SchemaConfig.Types (but that's still an additional thing to remember).

This would not cover all the use-cases for extensions. I think supporting both approaches would be ideal.

Thoughts?

Thanks, John

agu-z commented 4 years ago

@jberzy I think that's a great idea! I bet this would cover a lot of use cases.

A drawback I see is that if you make a typo in the type name or use the wrong 'Val, the compiler wouldn't catch it, which makes refactoring harder.

I think there isn't a way around this without defining the types in a top-level module. However, in my case, I wouldn't mind doing in so in exchange for safety.

This made me think of a simpler Extensions API which leverages ObjectRef and SchemaConfig.Types:

// Types.fs

let Query = Define.ObjectRef("Query")
let User = Define.ObjectRef<UserModel>("User")
let Organization = Define.ObjectRef<Organization>("Organization")

// User.fs

let types = [
  User.With([
      Define.Field("id", ID, ...)
  ])

  Query.With([
      Define.AsyncField("users", ListOf User, ...)
  ])

  Organization.With([
      Define.AsyncField("users", ListOf User, ...)
  ])
]

// Organization.fs

let types = [
  Organization.With([
      Define.Field("id", ID, ...)
  ])

  Query.With([
      Define.AsyncField("organizations", ListOf Organization, ...)
  ])

  User.With([
       Define.AsyncField("organizations", ListOf Organization, ...)
  ])
]

// Schema.fs

let schema = Schema(Types.Query, config = { Types = User.types @ Organization.types }) 

If the Schema constructor automatically merges types, each module only needs to expose a list of the ones they want to add to the schema—no orphaned types with this approach.

Besides, if ObjectRef has a With function, a developer like me can have a top-level module and reuse the names and types of objects. If they don't find this convenient, they can use Define.Object as you showed.

I think this would give the library a good scaling story:

A developer can start with a one-file schema. When that becomes a problem, they can split it by type. If they need mutual recursion, they can use ObjectRef. And from there, as the complexity and team size increases, they can incrementally move into the extensions pattern.

What do you think?

johnberzy-bazinga commented 4 years ago

@agu-z I like that approach. The only thing is they would most certainly need to add all types explicitly, rather than only when they're not "seen" from an entrypoint. I still think that's fine though. I also like the fact the "With" is recognizable from the F# type system.

agu-z commented 4 years ago

Yep, that's where the inspiration came from.

I think adding all types explicitly is not a problem. It's easy to put them on a lists and concat them at the end.

I'll start working on an ObjectRef implementation tonight. Extensions can come later.

Thanks for the feedback!