Hey Absinthe Team! Hope you're all well. :)
I'm working on a large greenfields API development effort with my team members @nwinch and @indieisaconcept over at @autopilothq.
We were foolhardy enough to decide that the premise of this API should be the dynamic definition of business concerns — (i.e. — wanna track/store/CRUD/manipulate something? Create a type for it!) — and so we currently have a REST-only API which is both strongly and dynamically typed. It was always our intention to expose this internal schema to clients via GraphQL, and this whole time we've had our fingers tightly crossed that we wouldn't have to implement our own GraphQL library in order to do so.
In order to test the feasibility of using absinthe for this feature, we did a brief spike. We ran into some problems with some assumptions that Absinthe makes, but were surprised and delighted to discover that not /all/ that much lies in the way of a completely dynamic schema, at least from a purely "just-get-it-done" perspective.
A number of problems stood out:
__absinthe_*__ methods an Absinthe schema contains.__absinthe_type__, etc.) it does so absent any context information from the request. This makes sense given a context-sensitive dynamic schema was never a requirement in the past, but makes it difficult for us to deliver a different schema for each authorised client of our API.After working out how to mimic the macro defined schema (straightforward enough) we needed to work out how to defeat the process cache. Originally we patched absinthe itself to remove this cache altogether, but this was eventually superseded by a different solution.
Rather than embark on a broad-scope refactor of absinthe to support passing contextual information about a request to the schema, in order to just get on with it and demonstrate feasibility we decided to
patch absinthe_plug to attempt every individual schema execution in a new process, and save the conn_info into the process dictionary of that new process. Then we can rely on the fact that our schema has access to that process dictionary, and read out the connection information (containing the details about the authorised client) from the process dictionary. Obviously this makes a lot of assumptions about the process structure of absinthe and isn't sustainable long term, for a multitude of reasons, but works for now. It also has the added effect of defeating the process dictionary caching in absinthe, removing the requirement for the previous cache-defeat patch in absinthe.
This patch is on a branch of our fork of absinthe_plug.
def run_query(query, conn_info, config) do
- %{document: document, pipeline: pipeline} = Request.Query.add_pipeline(query, conn_info, config)
+ # This is a nasty hack.
+ #
+ # The idea is that rather than a wide-reaching refactor spanning across both absinthe-plug and
+ # absinthe to ensure that contextual information is passed to the absinthe schema when requesting
+ # types, we can save that off to the side in the process dictionary. To ensure that each time the
+ # schema is generated or requested it is genuinely fresh, we use Task.async to create a fresh
+ # task every time, and thereby a new context.
+ #
+ # No, this is not good for performance, not a wonderful security pattern, breaks the referential
+ # transparency of schema generation in surprising and not-wonderful ways, but it does enable us
+ # to move forward with dynamic schemas per authorisation/user account in a pragmatic way.
+ #
+ # Ultimately we would like to integrate support for dynamic schemas into absinthe in a
+ # manner that aligns with the project goals, but that seems pretty far off. We need a way to get
+ # this done now, and this seems (for now) like the most reliable way to do so with the smallest
+ # surface area of changes in absinthe and absinthe-plug.
+
+ task = Task.async(fn() ->
+ :erlang.put(:conn_info, conn_info)
+ %{document: document, pipeline: pipeline} = Request.Query.add_pipeline(query, conn_info, config)
+ Absinthe.Pipeline.run(document, pipeline)
+ end)
- with {:ok, %{result: result}, _} <- Absinthe.Pipeline.run(document, pipeline) do
+ with {:ok, %{result: result}, _} <- Task.await(task) do
{:ok, result}
end
end
We were then able to implement a very hacky proof-of-concept schema with a couple of string fields which had definitions that changed depending on the authorised client (owner).
Forgive the scribbly nature of the code — hopefully this is understandable enough. We created a kind of pseudo-super-call to a blank schema so we could fill in some blanks, which is what the Superset module is doing.
schema.ex
defmodule Bureaucrat.Interfaces.GraphQL.SchemaBuilder do
@moduledoc false
require Logger
def build_schema(owner) do
date = DateTime.to_string(DateTime.utc_now)
schema_description =
"Autogenerated bureaucrat schema. Different every time! " <> date
%Absinthe.Type.Object{
description: schema_description,
fields: get_fields(owner),
identifier: :query,
interfaces: [],
is_type_of: nil,
name: "RootQueryType",
field_imports: []
}
end
def get_fields(:not_request_context) do
Logger.debug "somehow we're not in requst context (compile time?)"
Logger.debug "Sending blank schema."
%{}
end
def get_fields(owner) do
%{
"bus" => %Absinthe.Type.Field{
__private__: [],
args: %{},
complexity: nil,
config: nil,
default_value: nil,
deprecation: nil,
description: "Some kind of bus I guess",
identifier: "bus",
middleware: [
fn(resolution, _config) ->
Absinthe.Resolution.put_result(resolution,
{:ok,
"The bus was driven: #{DateTime.utc_now |> DateTime.to_string}"
}
)
end
],
name: "bus",
triggers: [],
type: :string
},
owner => %Absinthe.Type.Field{
__private__: [],
args: %{},
complexity: nil,
config: nil,
default_value: nil,
deprecation: nil,
description: "Private field only for #{owner}!",
identifier: owner,
middleware: [
fn(resolution, _config) ->
Absinthe.Resolution.put_result(resolution,
{:ok,
"#{owner} requested this field: " <>
DateTime.to_string(DateTime.utc_now)
}
)
end
],
name: owner,
triggers: [],
type: :string
}
}
end
end
defmodule Bureaucrat.Interfaces.GraphQL.Schema.Base do
@moduledoc """
The central GraphQL Schema for the API, this file maps out the root
level types.
"""
use Absinthe.Schema
alias Bureaucrat.Interfaces.GraphQL.Schema
require Logger
require Bureaucrat.Interfaces.GraphQL.SchemaBuilder
defmodule Superset do
@moduledoc """
This module contains a superset schema with resolvers for basic types.
"""
use Absinthe.Schema
query do
end
end
def __absinthe_types__ do
Logger.debug "looks like somebody is trying to introspect our types!"
IO.inspect(
Schema.Base.Superset.__absinthe_types__()
)
end
def __absinthe_type__(type) do
Logger.debug "Getting details for type #{inspect type}"
IO.inspect Schema.Base.Superset.__absinthe_type__(type)
end
def __absinthe_type__(:query) do
Logger.debug "Looks like somebody is trying to get our root query type."
Logger.debug "Our pid is: #{inspect self}"
Bureaucrat.Interfaces.GraphQL.SchemaBuilder.build_schema(get_auth_info())
end
def context(context) do
Logger.debug "Context for the request: #{inspect context}"
context
end
def get_auth_info do
case :erlang.get(:conn_info) do
:undefined -> :not_request_context
%{ conn_private: %{ context: %{ owner: owner }}} -> owner
end
end
end
This means we can run the following query (with an authorisation header mapping to amazingOwnerName) and get the result we expect:
request
query IntrospectionQuery {
__schema {
queryType {
name
description
}
}
bus
amazingOwnerName
}
result
{
"data": {
"bus": "The bus was driven: 2017-11-10 00:31:49.489956Z",
"amazingOwnerName": "amazingOwnerName requested this field: 2017-11-10 00:31:49.489983Z",
"__schema": {
"queryType": {
"name": "RootQueryType",
"description": "Autogenerated bureaucrat schema. Different every time! 2017-11-10 00:31:49.489075Z"
}
}
}
}
Yay! Crucially, the field amazingOwnerName does not exist for different API clients.
So, obviously the above isn't something you'd (ideally) want to keep around for the long term. But I don't think there's a crazy amount of work required to get it to a good place.
What we ideally need (in terms of a public interface contract) from Absinthe falls into three rough categories:
Rather than launch off and file an enormous PR which wasn't necessarily in keeping with ongoing efforts or the general project roadmap, our intention here is to let the project team know that this feature is important to us, start a discussion, and find out how we can work together to make it happen.
Given this is pretty critical for our project, and the project is critical for our business, we can absolutely contribute our own development time to make this happen — but we want to do so in a way that respects the project direction.
Let's have a chat about how best we can help out, and if you've got any better ideas about how to realise a feature like this. :)
Thanks for making absinthe what it is! Looking forward to working together.
Christopher
This is quite an impressive undertaking! I will have to dig into this in more detail.
As a quick question for you, have you considered the option of building a schema module dynamically at runtime? IE, you let your customers define some schema and then when they "save" it you use macros to generate a schema module for them, which will clobber the one in memory, and then carry on from there. This has generally been the route we've suggested to folks when they wanted some dynamic-ness to their schemas.
Hey! Really appreciate your swift response. :)
That's definitely something we thought about — but with n thousand (now, maybe millions later) individual authorisations with their own constantly updating schemas, we worried about the performance cost, and the overhead of ensuring consistency between hundreds of servers.
Do you think it's scalable? What's the compile cost of a module in a system like that? Are our fears unfounded?
Josh Adams of Elixir Sips (now dailydrip.com) did a screencast on a rules engine that was dynamically compiled at runtime a while back, I believe he performance tested it if you're curious to know the performance cost (I do remember it not being cheap). Keeping bytecode sync'd across servers will without a doubt be difficult if it needs to be atomic, especially if the module name isn't changing. Rather than overwriting a schema as Ben suggested, maybe consider versioning them and clobbering the old ones to keep memory down?
Also, if you're dynamically changing a statically typed interface, doesn't that mean your clients could potentially crash if you've overwritten something they were using? It seems to me to defeat the purpose of a statically typed interface to change it dynamically...
Josh Adams of Elixir Sips (now dailydrip.com) did a screencast on a rules engine that was dynamically compiled at runtime a while back, I believe he performance tested it if you're curious to know the performance cost (I do remember it not being cheap). Keeping bytecode sync'd across servers will without a doubt be difficult, especially if the module name isn't changing. Rather than overwriting a schema as Ben suggested, maybe consider versioning them and clobbering the old ones to keep memory down?
Thanks, will check that out!
Also, if you're dynamically changing a statically typed interface, doesn't that mean your clients could potentially crash if you've overwritten something they were using?
The idea is the clients are in solely control of the schema, rather than us. But we've got strategies for schema mutation safety (like deprecation, append-only migrations, etc.) which are similar to the ideas GraphQL itself espouses for safe API iteration. We're also providing machine readable errors with type hints, so clients can self-heal their local schema representation.
I did something in Ruby a few years back storing the schema definition in MongoDB and generating the Ruby class on demand. You're basically trying to create a data storage system, am I right? Essentially, it can create new types (like a table), migrate them ect. and then create new data (insert records) for those types, is that your use case?
Having looked through your implementation a bit more, how do you plan on handling circular references, ie:
object :user do
field :friends list_of(:user)
end
Right now it looks like you're trying to instantiate all the types at once in a big tree, but the schemas aren't trees they're graphs. Maybe I'm misunderstanding; I'm not entirely sure what the Superset module is doing. Either way if you're trying to have an actual Elixir structure representing an entire schema in memory you're going to run into issues. The current schema approach was created because schemas get _large_ and if you're rebuilding them on every request this can become quite an issue.
It's pretty late for me but I want to mention briefly some roadmap items that I think will be relevant here.
The plan for Absinthe 1.5 is to re-work the internals of how schemas get built. We've been very happy with the blueprint system for handling GraphQL documents, and we plan on using the same system to parse and build IDL schemas. Elixir macro schemas would also just generate blueprint trees. These trees represent the type definitions, and that makes it very easy to do passes over them like we do with query documents.
However, they do not themselves constitute a schema. The plan is to have a final phase where the tree is compiled into a module. The reason that we still want to aim for a compiled structure is that it provides some really fantastic performance characteristics. Literal values sit in what's called the "literal pool" and are incredibly fast to reference. More importantly, they don't require copying when being passed around between processes.
Long story short: The 1.5 changes will make it easier to produce schemas without requiring macros to define them. However the goal will still be to target compiling actual modules, because these are the fastest possible shared key value store in the Elixir language, and it's a fundamental assumption of the execution engine that looking up a type is fast.
Tomorrow I'd be happy to look at addressing concerns about handling that kind of schema creation, and I'm also happy to dig in further to better understand the approach you're taking. However it's my strong gut feeling that sticking with a compiled schema is the way to go.
Thanks (both of you) for taking the time to respond so thoughtfully!
I did something in Ruby a few years back storing the schema definition in MongoDB and generating the Ruby class on demand. You're basically trying to create a data storage system, am I right? Essentially, it can create new types (like a table), migrate them ect. and then create new data (insert records) for those types, is that your use case?
Kinda, but almost... "as a service". The ultimate end user of a client of our API presses a button which connects service X to us — it exports its own schema definition into our API and we know how to talk their language and store their data. It could be anything — an IoT device, an e-commerce platform, an asset manager. The user might have hundreds or thousands of these integrations, or be creating loads of their own types manually.
Our API ultimately operates as an actor in a graph of connected services, which are constantly exporting new service definitions to one another, and performing semantic unattended service discovery. The graphs of objects and types that are known to a given user of a given node in the network can then be queried or manipulated via GraphQL, or REST, or any number of any supported interfaces.
Having looked through your implementation a bit more, how do you plan on handling circular references [...]
Forgive the code example — I probably should've spent a lot longer coming up with a nontrivial and more-real-world case. It's our intention to define the types and structure lazily wherever we can, given that (smart caching notwithstanding) a single type request could ultimately result in a DB lookup.
Don't pay too much attention to the Superset module — that was only intended to create a "clean" schema to which we could delegate function calls or queries we didn't want to trap locally or understand, and was really only intended to ease "reverse-engineering" (of a sort) the schema definition so as to implement it bare. This isn't something we'd put into our app — rather, a proof of concept answer to the question "can we get a schema dynamically defined at all?"
It's not particularly clean or well architected — apologies if that's made it hard to understand our intention here!
That said, here's a demonstration of a similar toy schema capable of handling circular references: https://gist.github.com/cgiffard/929f5e122e0599ac000713124b204572
However the goal will still be to target compiling actual modules, because these are the fastest possible shared key value store in the Elixir language, and it's a fundamental assumption of the execution engine that looking up a type is fast.
I don't mind this necessarily — if we have to change our strategy for type lookup in the interests of performance, it's probably a beneficial change generally.
That said, our constraints are that schemas cannot always be known upfront, and that we anticipate very rapid, constant changes to a large number of individual schemas by a large number of users (certainly far more than we'd reasonably fit in memory for a given node.) It all comes down to — what's the compile time like for schemas that are not "hot" on a given node? Is it a good pattern to unload modules after a time like an LRU, or will this cause problems? How many concurrent separate schemas can it reasonably scale to? Should we lazily compile? These are questions I don't have a good intuition for, unfortunately.
Have you seen anybody tackling similar dynamic schema problems with GraphQL generally?
@cgiffard The largest example of something close to this in action is probably Graph.cool's implementation, which is built on top of Scala's GraphQL implementation Sangria; they recently open sourced their framework and you can take a look here: https://github.com/graphcool/framework (Obviously it's not an small project.)
I wrote most of the (horrifically complex) schema definition macro logic, and as @benwilson512 points out, we're looking at refactoring it to use a different mechanism in v1.5 (to expand our pipeline extensibility features into how schemas are "written," and to make sure that the same schema compilation process used for macro-based definitions will also work with our new IDL (actually, SDL, or Schema Definition Language, these days) support.
Dynamic generation (loading) of schemas on each request (if I catch your drift, this is what you're doing) is obviously a whole new twist on the problem domain, and something for us to keep in mind while we look at refactoring what we have. Spitballing: I'm personally wondering if storing/reifying a schema from SDL might end up being the ticket here, keeping Ben's comments on performance concerns in mind.
I'm going to read through this a bit more carefully with a fresh pair of eyes in the morning and see if I have any more helpful ideas. It may very well make sense for us to end up on a call/Skype/Hangout sometime soon to talk more about motivations and chat about tradeoffs. We went through a tremendous number of different schema implementations early on in the project, and had to address a lot of the deep issues here in great and painful detail. I'm confident we can help, if even to warn you where some of the particularly deep holes/dead ends are.
So, at the risk of responding with vague statements with very little information, here are two steps we'll be doing in the short-to-mid term (targeting v1.5) that are related to this problem space.
Absinthe.Schema behaviour more strictly defined. Right now, although we have defined callbacks, other parts of the Absinthe ecosystem regularly "reach through" and use what amounts to internal functions (eg, __absinthe_*__). With this in place, you should be able to build schemas (that implement the behaviour) with the confidence they'll work.Absinthe.Blueprint.Schema structs (either by using our macros or by providing SDL), that will then be processed by a pluggable pipeline—with the last phase generating the compiled schema module. Perhaps (vague statement follows), this pipeline could be used dynamically with a special Absinthe.Schema implementation (that knows how to load types, etc, from the blueprint description w/out compiling it).I need to think and experiment with this a bit. The issue here is really the timeline; while "1" above I could see me doing pretty easily in the short-term, the work described in "2" is the main brunt of work for v1.5, which we're not going to start in earnest until after a couple weeks of mainline use of v1.4, and completion of our book (we have one chapter left to write, due in a couple of weeks). It's also work that builds on 2+ years of building Absinthe's internals, so not something we can offload very easily.
I'm sorry I don't have a more positive, collaborative coding answer here—I wish that were the case—but perhaps further refinement of what you're trying to do and further discussion about what's missing/needed can help inform the direction (and needed flexibility) of the work for v1.5 a bit.
Hey @bruce — thanks so much for following up. Really appreciate the time and attention you're giving this, and definitely understand you guys are under the pump with respect to your book (we bought a copy or two a little while back for our office as it happens!)
I think some strictness (maybe even a behaviour or some kind of formal pattern for how a schema should look) as well as some docs (more than happy to help out with that!) around those hook methods or the scaffolding mechanism used to build them would be useful, and it does sound like the thrust of the work you have planned for 1.5 will make our interface with absinthe a lot stabler long term.
Generally I think we're happy to accept that our case is a bit of an unusual one, and that probably won't be the focus of development effort, as long as we're not writing against a private API or something likely to be pretty fragile with respect to updates.
Reiterating (and further distilling) what we talked about previously, for our project, our key requirements are:
Given I think we're relatively aligned in terms of our expectations, and that it sounds like Absinthe 1.5 will answer a lot of our challenges, we'll probably roll-forward with writing against 1.3/1.4 using our hacky method, with a fairly decoupled architecture that should allow us to migrate to a mutually supported mechanism atop 1.5.
As part of that, we're more than happy to contribute development time and effort — if there are key pieces that stand in the way of 1.5, let me know and I'm sure we can assist. (Even if it's stuff off to the side like documentation.) Working with you on the schema definition mechanism will be a great way to get atop it for our own integration purposes too.
@cgiffard We'll definitely keep those items in mind. Thanks again for putting together such a thorough exposition of the idea.
I think it's fair to say that, provided your proposed changes are backwards-compatible, we'd be more than willing to discuss separate, narrowly focused PRs for the latter two items (optional disabling of the type cache, and connection context-related features) for inclusion in v1.4. Helpers for dynamic definition of schemas will have a longer wait, and will rely more heavily on our efforts for v1.5.
So, we've been building against this in earnest for a couple of weeks now, so I thought I'd check in with a report about what's working and what's problematic for us.
Working well:
__absinthe_type__/1 and __absinthe_types__/0 and return the appropriate structs) means we're able to split out our mapping code and keep the concerns relatively isolated from both absinthe and from the rest of our application.Apart from the items previously identified, we have some problems with:
nil value originally came from.binary interface.) As far as I'm able to ascertain, GraphQL explicitly allows setting the type of a field to an interface — We'll continue to work through these, and file issues and PRs wherever we can, and in the best way we can — but given we're all just about to go on holidays I wanted to get in before then and give a little update.
Absinthe is working really well for us so far — in spite of our bizarre requirements — and we're really excited to have a pretty complete dynamic schema working with it. Defining a new type via GraphQL and seeing the schema update automatically to accommodate it pretty fun. :)
Thanks again all!
Scratch that interfaces-as-a-field issue — it seems like the very latest version of Absinthe has resolved it. Previously, interfaces which referred to themselves caused infinite loops during introspection, like the below:
defmodule Cars.Schema do
@moduledoc "Retrieve an automobile!"
use Absinthe.Schema
interface :vehicle do
field :type, :string
field :has_wheels, :boolean
field :chronological_predecessor, :vehicle
resolve_type fn(object, _) -> object.type end
end
query do
field :automobile, :vehicle
end
end
Now everything seems to be working! (Sorry for the erroneous complaint!)
Hey @cgiffard I'm impressed to hear about the amount of progress you've made, and I can't thank you enough for your kind words about the code and documentation :)
To your points:
If we didn't document this somewhere then we should have, identifiers are definitely expected to be atoms at the moment. The original concept was that the :identifier was the sort of Elixir internal name, and that the :name would be the client external name. This has been blurred a bit in that adapters also sort of control how things present externally.
More to the point though, I do actually think that if all the references to types were strings IE field :foo, "user" then string identifiers would generally work. Notable exceptions are the top level special objects [:query, :subscription, :mutation] which are referred to by those particular identifiers. However that isn't really an issue for you cause there aren't N of those.
All of this is to say that I think supporting string identifiers should be probably doable, and fully compiled schemas can just use atom identifiers as an optimization.
Hm, not entirely sure what's up there. One thought I had as far as testing goes is that you could add the dynamic schema builder to a fork of Absinthe, and then re-create some of the schemas used in the Absinthe tests with your dynamic builder, and then run the Absinthe test suite with them. This might help uncover mismatches. Although as I think about it more most of the tests use the language conventions adapter, so this may not help a ton with this specific issue.
There are definitely more rules that could be in place. Right now the rules list isn't super pluggable, which makes it difficult for folks to contribute to it. The planned 1.5 changes would make this easier, but obviously that doesn't help you a ton now. If you develop any rules based on the spec please feel free to do a PR, and we'd be happy to offer help if you run into issues.
Schema.lookup_type/2 #=> nilYeah this is a tough one. schema_node: nil does hold meaning within the system and so it has generally made sense to have lookup_type return nil for types that didn't exist, as this generally simplified the schema node annotation code. At least part of the reasoning here is historical as well, in that we first developed a working schema building system, and once that was reasonably well tested we built out everything else. You're going in the reverse order of course and so some of the assumptions are less strong.
I think you could convince me that for 1.5 lookup_type should return {:ok, type} | :no_type or something of that sort. The primary advantage of this is that there are phases where looking up a type absolutely should work. Validations have already happened, and if we can't lookup a type that's referenced it means that the validations aren't doing their job. This assertiveness right now however is implicit, but if we made the above change it could be explicit: {:ok, type} = Schema.lookup_type(schema, identifier). Places where the type may not exist would also be more explicit, which I think would be worthwhile even if it added a line of code here or there.
Notably of course this can't happen until 1.5 as it would constitute a breaking change.
A lot that I've mentioned here is either contingent upon or supposed to be significantly improved in v1.5. It's been our plan to create a v1.4 branch of Absinthe, and then turn master into 1.5-dev soon. The primary blocker here is basically the book. We want the book to be able to run completely under 1.4, which means we may still make a tweak here and there to 1.4 before it's completed.
I'll talk to @bruce though. Once we make master 1.5-dev we could do a pass to implement the lookup_type tuple return for example which I think would make your life a fair bit easier.
Status update: This is largely much more possible now. The primary remaining items are:
I realize this may not carry as much weight as a large greenfield project depending on this, but for what it's worth, I've been following this very excitedly because dynamic sachems would be a perfect fit for a side project of mine. Just wanted to share that this is potentially useful for more than just one use case.
For what it's worth, the use-case I could really use this would be a runtime definable profile for users (with fields of certain type with certain restrictions). But I guess this functionality could also be a good fit to build something with functionality along the lines of the hasura graphql engine where the runtime (database) definition determines what the schema will look like.
@benwilson512 are the only remaining things to do documentation and tests (with some fixes eventually for "string identifiers"?
I was wondering if there is an update on this, or at least a small snippet of 1 dynamic field of how to do this with 1.5.
An update on the progress of this would be amazing!
Absinthe 1.5 has been out for a while, so I'm closing this issue! If there are particular ideas / features that folks have that aren't addressed, please feel free to open new issues.
There's a number of tests showing dynamic schema generation, including this one that adds custom introspection fields and types:
Most helpful comment
Status update: This is largely much more possible now. The primary remaining items are: