graphjin icon indicating copy to clipboard operation
graphjin copied to clipboard

Custom mutations for embedded engine

Open bilus opened this issue 4 years ago • 3 comments

A great project and concept I'm trying to merge with CQRS. I'm on vacation so with a little time on my hands, I wrote a simple Go library for CQRS + Event-sourcing because why not. It seems like a perfect match with graphjin as-a-library because it lets you write materializers writing to a database and your read views require no further coding.

The only issue I'm having is triggering CQRS commands as mutations as the only way to manipulate the database (so no CRUD insert/update etc.). What I'm currently doing is intercepting mutations and handling them using custom code while passing on all queries.

It works but doesn't play well with introspection:

  1. The custom mutations are not available to introspection.
  2. The CRUD mutations are.

How would you suggest I move on with improving ^? I don't mind getting my hands dirty and submitting a patch but is there anything you would suggest as a starting point? Should I just make it possible to modify the schema and leave the custom intercepting code as is? Or is there a way I could plug in custom mutations?

I think custom mutations would be a great addition, or am I wrong?

bilus avatar Jun 19 '21 09:06 bilus

Can you give me an example of what you mean by "custom mutation" ? Maybe one way to do this might be for us to have a export schema function that providers the schema in json you can then add or modify it and use that.

dosco avatar Jun 19 '21 18:06 dosco

Here's an example:

mutation {
  increment(aggregateId: "aa", aggregateType: "counter")
}

This triggers a command on the aggregate.

Here's the full handler so you see how it's currently hooked up (WIP, there's a lot of room for refactoring/cleanup):

func GraphQLHandler(gj *core.GraphJin, repo *es.Repository) gin.HandlerFunc {
	ctx := context.Background() // TODO(bilus)
	return func(c *gin.Context) {
		body, err := ioutil.ReadAll(c.Request.Body)
		if err != nil {
			c.AbortWithStatusJSON(http.StatusBadRequest, statusJSON(err))
			return
		}

		request := GraphQLRequest{}

		if err := json.Unmarshal(body, &request); err != nil {
			c.AbortWithStatusJSON(http.StatusBadRequest, statusJSON(err))
			return
		}

		commandHandler := es.NewCommandDispatch(repo)

		ctx := context.WithValue(ctx, core.UserIDKey, "user id")

		op, _ := core.Operation(request.Query)
		if op == core.OpMutation {
			if err := es.HandleMutation(commandHandler, request.Query, request.Variables); err != nil {
				c.AbortWithStatusJSON(http.StatusBadRequest, statusJSON(err))
				return
			}
			c.JSON(http.StatusOK, struct{}{})
			return
		}
		res, err := gj.GraphQL(ctx, request.Query, request.Variables, nil)
		if err := json.Unmarshal(body, &request); err != nil {
			c.AbortWithStatusJSON(http.StatusInternalServerError, statusJSON(err))
			return
		}

		c.JSON(http.StatusOK, res)
	}
}

And here's how it's handled:

func HandleMutation(handler CommandHandler, mutationQuery string, variablesRaw []byte) error {
	op, _ := core.Operation(mutationQuery)
	if op != core.OpMutation {
		return errors.New("only mutations can trigger commands")

	}

	q := &schema.QueryDocument{}
	if err := q.Parse(mutationQuery); err != nil {
		return err
	}

	variables := make(map[string]interface{})
	if err := json.Unmarshal(variablesRaw, &variables); err != nil {
		return err
	}

	for _, operation := range q.Operations {
		for _, selection := range operation.Selections {
			fs, ok := selection.(*schema.FieldSelection)
			if !ok {
				return errors.New("unexpected graphql selection")
			}
			if err := handleMutation(handler, fs, variables); err != nil {
				return fmt.Errorf("operation %q failed: %w", fs.Name, err)
			}
		}
	}

	return nil
}

func handleMutation(handler CommandHandler, fs *schema.FieldSelection, variables map[string]interface{}) error {
	// TODO(bilus): Validate command (missing ID)
	methodName := fs.Name
	args := fs.Arguments.Value(variables)

	command, err := Commands.Make(methodName, args)
	if err != nil {
		return err
	}
	return handler.Dispatch(handler, command)
}

bilus avatar Jun 21 '21 17:06 bilus

Exporting schema is ok but I still need a way to add the mutations programmatically to the schema. Is there a way for you to expose the chirino schema, making it possible to declare additional mutations? I know you want to encapsulate the dependency but I'm fine even if you clearly state that this is an internal API that should not be relied upon :).

Alternatively, how about adding support for mutation resolvers? Are you planning to do add that at some point?

I really see a great potential in combining CQRS with graphjin because it gives you read models for free (you just write a materializer) while avoiding coupling client-side code to database schema (because the schema is FOR the client anyway). I'm working on an app using graphjin + my CQRS library and the experience is really great. Introspection is important to me though because (a) I want to make my library easy to use with graphjin (b) I'm generating client-side Elm code based on introspection and manually hacking the schema to include mutations is not the best workflow.

bilus avatar Jun 22 '21 19:06 bilus