Code Monkey home page Code Monkey logo

operation-hooks's Introduction

Operation Hooks

This is a PostGraphile server plugin which encompasses a collection of Graphile Engine plugins enabling you to register asynchronous callbacks before/during/after operations; uses include:

  • validation - check that the incoming arguments are valid
  • authorization - check that the user is permitted to take that action
  • error - aborting the action for some reason (e.g. insufficient funds)
  • notification - inform the user of hints, validation errors, warnings, success, and relevant meta-information (e.g. remaining balance)
  • mutation pre-flight - do the preliminary checks of mutation (and throw any errors they may raise) without actually doing the mutation

The callbacks only affect root fields (e.g. fields on the Query, Mutation and Subscription types) and can:

  • exit early (with or without an error) - preventing the operation being executed
  • augment the result of the operation (typically in order to add additional information)
  • accumulate metadata from before/after the operation
  • accumulate metadata during the operation (mutations only; e.g. via trigger functions)
  • augment error objects with said metadata

Usage:

PostGraphile CLI:

postgraphile \
  --plugins @graphile/operation-hooks \
  --operation-messages \
  --operation-messages-preflight

(--operation-messages exposes generated messages on mutation payloads and GraphQL error objects; --operation-messages-preflight adds a preflight option to mutations which allows the pre-mutation checks to run (and messages to be generates) but does not actually perform the mutation.)

PostGraphile library:

const { postgraphile, makePluginHook } = require("postgraphile");

// This is how we load server plugins into PostGraphile
// See: https://www.graphile.org/postgraphile/plugins/
const pluginHook = makePluginHook([
  require("@graphile/operation-hooks").default,
  // Any more PostGraphile server plugins here
]);

const postGraphileMiddleware = postgraphile(DATABASE_URL, SCHEMA_NAME, {
  pluginHook,
  operationMessages: true,
  operationMessagesPreflight: true,
  appendPlugins: [
    // Add your JS hooks here, e.g.
    // require('./path/to/my_hook.js')
  ],
});

// This example uses `http` but you can use Express, Koa, etc.
require("http").createServer(postGraphileMiddleware).listen(5000);

/*
const app = express();
express.use(postGraphileMiddleware);
express.listen(5000);
*/

If you want to just use the Graphile Engine plugins without the PostGraphile CLI/library integration that's possible too:

const { createPostGraphileSchema } = require("postgraphile");
const { OperationHooksPlugin } = require("@graphile/operation-hooks");

const schema = createPostGraphileSchema(DATABASE_URL, SCHEMA_NAME, {
  appendPlugins: [OperationHooksPlugin],
});

Messages (notifications)

The messages plugin gives you the ability to associate messages with an operation. Each message has at least a level and message field (both are strings).

Imagine you have the following GraphQL mutation:

input SendEmailInput {
  email: String!
  subject: String
  body: String
}
extend type Mutation {
  sendEmail(input: SendEmailInput!): SendEmailPayload
}

There's a number of messages you might be interested in sending:

  • Validation errors (abort) or warnings (hint, but don't abort):
    • [B] level: 'error', message: 'Invalid email address - must contain at least one @ symbol', path: ['input', 'email']
    • [B] level: 'warning', message: 'Missing subject', path: ['input', 'subject']
    • [E] level: 'error', message: 'The domain for this email is unreachable', path: ['input', 'email']
  • Authorization issues:
    • [B] level: 'error', message: 'You must be on a paid plan to send emails'
    • [B] level: 'error', message: 'You are not permitted to email this address', path: ['input', 'email']
  • Business requirements:
    • [B] level: 'error', message: 'Insufficient credits to send email', remaining_credits: 2, required_credits: 7
    • [A] level: 'warn', message: 'Your credit is very low', remaining_credits: 9, required_credits: 7
  • Notices:
    • [E] level: 'error', message: 'Email sending is not available at this time, please try again later'
    • [B] level: 'notice', message: 'Emails are currently subject to a 3 minute delay due to abuse circumvention; normal service should resume shortly'
    • [A] level: 'notice', message: 'Email sent, remaining credits: 177', remaining_credits: 177
    • [A] level: 'notice', message: 'You have 2 unsent emails in your outbox, please review them'

You'll notice that every message has a level string and message string, many also have a path string array. All messages can optionally define additional arbitrary keys. I've also tagged each one [B] for "before" (i.e. this message would be generated before the mutation takes place), [A] for "after" (i.e. this message would be generated during or after the mutation), and [E] for "error" (i.e. this message may be generated if an error occurred during the mutation itself). Note that the [A] (after) messages might also be triggered during the mutation, rather than afterwards; more on this below.

The level key is treated specially; if any message generated before the mutation takes place produces a message with level='error' then the mutation will be aborted with an error. The value in doing this with these messages is that more than one error (along with associated warnings, notices, etc.) can be raised at the same time, allowing the user to fix multiple issues at once, resulting in greater user satisfaction.

Messages are accumulated from all the operation hooks that have been added to the current mutation. One hook producing a message with level=error will not prevent further hooks from being called (however you can prevent other hooks from being called by literally throwing an error).

Exposing messages

Should you wish to surface notifications via GraphQL (rather than just using the before/after hooks to cause side effects, or possibly raise 'error' messages), you may use the CLI flag --operation-messages or library config operationMessages: true. Doing so will extend the mutation payloads in your GraphQL schema with a messages entry, a list of the messages raised, and will also expose relevant messages on any thrown GraphQL errors.

We will define an OperationMessageInterface interface that all messages must conform to:

interface OperationMessageInterface {
  level: String!
  message: String!
  path: [String!]
}

And extend all mutation payloads to expose them:

extend type *MutationPayload {
  messages: [OperationMessageInterface!]
}

You can then define whatever concrete message subtypes you need to be returned. A message type must specify at least the 3 fields defined in the interface:

  • level (required, string)
    • e.g. error, warning, notification, info, debug, ...
    • helps client figure out how to use the message
    • error is special - it will abort the transaction on the server (all others are just passed to client)
  • message (required, string)
    • e.g. Invalid email
    • a human-readable error message; fallback for if the client does not understand the rest of the payload
  • path (optional, string array)
    • e.g. ['input', 'userPatch', 'firstName']
    • application developer may find other uses for this, so no further validation will be done
    • typically denotes the path to the field that caused the error

⚠️ Please note that messages added to errors do NOT conform to the GraphQL definitions, so be careful to not expose more information than you intend!

SQL NOTICEs

This is the easiest way to add messages during a mutation; you just need to RAISE NOTICE in one of the functions related with your mutation. This could be your custom mutation function itself, or it could be a trigger function called by one of the rows you're manipulating.

Importantly, the NOTICE must use the error code OPMSG. It may optionally define detail which is treated as a JSON value and is merged into the message object; if no level key is included as part of detail then the level will default to info.

Minimal example:

RAISE NOTICE 'Your credits are running low.' USING ERRCODE = 'OPMSG';

Fuller example:

RAISE NOTICE
  '2 + 2 is %, minus 1 that''s %; quick maths.',
  (2 + 2),
  (2 + 2 - 1)
USING
  ERRCODE = 'OPMSG',
  DETAIL = json_build_object(
    'level', 'info',
    'path', array_to_json(ARRAY['noticePath']),
    'anything_else', 'can_go_here'
  )::text;

See the PostgreSQL RAISE documentation for more information.

SQL hooks

Adding this schema plugin to a PostGraphile server will give you the ability to define mutation operation hooks via PostgreSQL functions. These hooks only apply to the built in CRUD mutations, for custom mutations or schema extensions you should implement the logic within the mutation (or use the JS hooks interface).

SQL function requirements

To be detected as a mutation operation hook, these PostgreSQL functions must conform to the following requirements:

  • Must be defined in an exposed schema (may be lifted in future)
  • Must be named according to the SQL Operation Callback Naming Convention (see below)
  • Must accept the first 0, 1, 2 or 3 of the following arguments:
    • data (JSON/JSONB) - the data the user submitted to be stored to the record (INSERT: the create object, UPDATE: the patch object, DELETE: null)
    • tuple (table type) - the current row in the database (because we expose a lot of methods to mutate the same row)
      • before insert: null
      • after insert: the new row, using the primary key
      • before update: the old row, using the unique constraint
      • after update: the new row, using the primary key
      • before delete: the old row, using the unique constraint
      • after delete: null
    • op (string) - the operation (insert, update, or delete) - useful if you want to share the same function between multiple operations
  • Must return:
    • VOID, or
    • SETOF mutation_message, or
    • mutation_message[], or
    • TABLE(level text, message text, path text[], ...)
    • or can be defined using OUT parameters matching the TABLE entries above.
  • Must be either VOLATILE (default) or STABLE (note: should only be STABLE if it does not return VOID)

Recommendation: add an @omit smart comment to the function to have it excluded from the GraphQL schema.

Example

SQL schema:

create table users (
  id serial primary key,
  username text not null unique
);
create type mutation_message as (
  level text,
  message text,
  path text[],
  code text
);

create or replace function users_insert_before(input jsonb)
returns mutation_message[]
as $$
begin
  if lower(input ->> 'username') <> (input ->> 'username') then
    return array[(
      'error',
      'Your username must be in lowercase',
      null,
      'E83245'
    )::mutation_message];
  else
    return array[(
      'info',
      'Nice to meet you, ' || (input ->> 'username'),
      null,
      null
    )::mutation_message];
  end if;
end;
$$ language plpgsql stable;

comment on function users_insert_before(jsonb) is E'@omit';

GraphQL mutation:

mutation {
  createUser(input: { user: { username: "alice" } }) {
    user {
      username
    }
    messages {
      level
      message
    }
  }
}

Result:

{
  "data": {
    "createUser": {
      "user": {
        "username": "alice"
      },
      "messages": [
        {
          "level": "info",
          "message": "Nice to meet you, alice"
        }
      ]
    }
  }
}

SQL operation callback naming convention

By default we use the following naming convention:

  • start with the GraphQL table name and an underscore (e.g. users_)
  • followed by the SQL operation name, lowercase (insert, update or delete)
  • followed by _before or _after to indicate when it runs

e.g. users_insert_before

You can override this using the inflector pgOperationHookFunctionName:

const { makeAddInflectorsPlugin } = require("graphile-utils");

module.exports = makeAddInflectorsPlugin(
  {
    pgOperationHookFunctionName: (table, sqlOp, when, _fieldContext) => {
      return `${table.name}_${sqlOp}_${when.toLowerCase()}`;
    },
  },
  true
);

Implementing operation hooks in JavaScript

You can also implement hooks in JavaScript (the SQL hooks are actually implemented using the JavaScript interface). To do so, you use the addOperationHook API introduced by this plugin. This allows you to write a single function that handles all root-level queries, mutations and subscriptions; it's then your responsibility to filter this down to what you need. (We'll probably make a helper for this in future!)

You can load your plugin with the standard --append-plugins (library: appendPlugins) option.

What follows is an example plugin, you can see it in use in this example repository.

// This plugin logs all attempts at `create` mutations before they're attempted.

const logCreateMutationsHookFromBuild = (build) => (fieldContext) => {
  // This function is called for every top-level field registered with
  // Graphile Engine. `fieldContext` is a Context object describing
  // the field that's being hooked; it could be for a query (`isRootQuery`),
  // a mutation (`isRootMutation`), or a subscription
  // (`isRootSubscription`). Return `null` if we don't want to apply this
  // hook to this `fieldContext`.
  const {
    scope: { isRootMutation, isPgCreateMutationField, pgFieldIntrospection },
  } = fieldContext;

  // If your hook should only apply to mutations you can do this:
  if (!isRootMutation) return null;

  // You can further limit the functions this hook applies to using
  // `fieldContext`, e.g. `fieldContext.scope.fieldName` would allow you to
  // cherry-pick an individual field, or
  // `fieldContext.scope.isPgCreateMutationField` would tell you that this
  // is a built in CRUD create mutation field:
  // https://github.com/graphile/graphile-engine/blob/7d49f8eeb579d12683f1c0c6579d7b230a2a3008/packages/graphile-build-pg/src/plugins/PgMutationCreatePlugin.js#L253-L254
  if (
    !isPgCreateMutationField ||
    !pgFieldIntrospection ||
    pgFieldIntrospection.kind !== "class"
  ) {
    return null;
  }

  // By this point, we're applying the hook to all create mutations

  // Defining the callback up front makes the code easier to read.
  const tableName = pgFieldIntrospection.name;
  const logAttempt = (input, args, context, resolveInfo) => {
    console.log(
      `A create was attempted on table ${tableName} by ${
        context.jwtClaims && context.jwtClaims.user_id
          ? `user with id ${context.jwtClaims.user_id}`
          : "an anonymous user"
      }`
    );

    // Our function must return either the input, a derivative of it, or
    // `null`. If `null` is returned then the null will be returned (without
    // an error) to the user.

    // Since we wish to continue, we'll just return the input.
    return input;
  };

  // Now we tell the hooks system to use it:
  return {
    // An optional list of callbacks to call before the operation
    before: [
      // You may register more than one callback if you wish, they will be mixed
      // in with the callbacks registered from other plugins and called in the
      // order specified by their priority value.
      {
        // Priority is a number between 0 and 1000; if you're not sure where to
        // put it, then 500 is a great starting point.
        priority: 500,
        // This function (which can be asynchronous) will be called before the
        // operation; it will be passed a value that it must return verbatim;
        // the only other valid return is `null` in which case an error will be thrown.
        callback: logAttempt,
      },
    ],

    // As `before`, except the callback is called after the operation and will
    // be passed the result of the operation; you may return a derivative of the
    // result.
    after: [],

    // As `before`; except the callback is called if an error occurs; it will be
    // passed the error and must return either the error or a derivative of it.
    error: [],
  };
};

// This exports a standard Graphile Engine plugin that adds the operation
// hook.
module.exports = function MyOperationHookPlugin(builder) {
  builder.hook("init", (_, build) => {
    // Register our operation hook (passing it the build object):
    build.addOperationHook(logCreateMutationsHookFromBuild(build));

    // Graphile Engine hooks must always return their input or a derivative of
    // it.
    return _;
  });
};

Caveats

Don't try and use this for things like field masking since there's a lot of different ways a user can access a field in GraphQL. Field masking should be solved via makeWrapResolversPlugin or similar approach instead.

This is a young plugin, it will evolve over time.

We don't currently have a neat way for adding other types to the OperationMessageInterface, so if you really need to expose additional fields, you can do it using a schema extension:

const { gql, makeExtendSchemaPlugin } = require("graphile-utils");

module.exports = makeExtendSchemaPlugin(() => ({
  typedefs: gql`
    extend type OperationMessage {
      anotherField: String
      yetAnotherField: Float
    }
  `,
  resolvers: {},
}));

operation-hooks's People

Contributors

benjie avatar dependabot[bot] avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar

Forkers

jcfinnerup kzlar

operation-hooks's Issues

failed assertion at PgNoticeMessagesPlugin: "processNotice should not be set yet!"

Summary

I have come across an assertion error message when using operation-hooks in combination with a different plugin "postgraphile-plugin-many-create-update-delete" (hereafter "mn-plugin"). Both plugins appear to be working perfectly when not used together and the error is thrown when attempting to make a mutation pursuant to the mn-plugin; however the error is thrown by an assertion of the operation-hook plugin. Also, if I comment out the assertion located at line 39 of the source file "PgNoticeMessagesPlugin" that appears to resolve the conflict; however, I do not know enough about the plugin system to understand the basis for the assertion.

Steps to reproduce

  "dependencies": {
    "@graphile/operation-hooks": "^1.0.0",
    "postgraphile-plugin-many-create-update-delete": "^1.0.6"
  },

After standard pg-library setup, preform a mn-plugin mutation:

mutation {
  mnCreateIndividual(
    input: {
      mnIndividual: [
        { first: "John", last: "Doe" }
        { first: "Jane", last: "Doe" }
      ]
    }
  ) {
    query {
      individuals {
        nodes {
          first
          last
        }
      }
    }
  }
}

Expected results

This is the correct result that I get when commenting out the assertion located at line 39 of the source file "PgNoticeMessagesPlugin":

{
  "data": {
    "mnCreateIndividual": {
      "query": {
        "individuals": {
          "nodes": [
            {
              "first": "John",
              "last": "Doe"
            },
            {
              "first": "Jane",
              "last": "Doe"
            }
          ]
        }
      }
    }
  },
  "explain": [
    {
      "query": "with __local_0__ as (\n          INSERT INTO \"public\".\"individuals\" \n          (\"individual_id\", \"tenant_id\", \"first\", \"last\", \"prefix\", \"middle\", \"gender\", \"suffix\", \"ssn\", \"birth\", \"death\")\n            VALUES (default, default, $1, $2, default, default, default, default, default, default, default),(default, default, $3, $4, default, default, default, default, default, default, default) returning *) select ((case when __local_0__ is null then null else __local_0__ end))::text from __local_0__",
      "plan": "CTE Scan on __local_0__  (cost=0.03..0.08 rows=2 width=32)\n  CTE __local_0__\n    ->  Insert on individuals  (cost=0.00..0.03 rows=2 width=1480)\n          ->  Values Scan on \"*VALUES*\"  (cost=0.00..0.03 rows=2 width=1480)"
    },
    {
      "query": "with __local_0__ as (select (str::\"public\".\"individuals\").*\nfrom unnest(($1)::text[]) str) select to_json(__local_0__.\"individual_id\") as \"@ophookpk__individual_id\"\nfrom __local_0__ as __local_0__\n\nwhere (TRUE) and (TRUE)\n\n\n",
      "plan": "Function Scan on unnest str  (cost=0.00..0.04 rows=2 width=32)"
    },
    {
      "query": "with __local_0__ as (select to_json((json_build_object('__identifiers'::text, json_build_array(__local_1__.\"individual_id\"), 'first'::text, (__local_1__.\"first\"), 'last'::text, (__local_1__.\"last\")))) as \"@nodes\" from (select __local_1__.*\nfrom \"public\".\"individuals\" as __local_1__\n\nwhere (TRUE) and (TRUE)\norder by __local_1__.\"individual_id\" ASC\n\n) __local_1__), __local_2__ as (select json_agg(to_json(__local_0__)) as data from __local_0__) select coalesce((select __local_2__.data from __local_2__), '[]'::json) as \"data\" ",
      "plan": "Result  (cost=13.18..13.19 rows=1 width=32)\n  InitPlan 1 (returns $0)\n    ->  Aggregate  (cost=13.16..13.17 rows=1 width=32)\n          ->  Sort  (cost=11.91..12.04 rows=50 width=1480)\n                Sort Key: __local_1__.individual_id\n                ->  Seq Scan on individuals __local_1__  (cost=0.00..10.50 rows=50 width=1480)"
    }
  ]
}

Actual results

The exact error message as it appears in graphiql is:

{
  "errors": [
    {
      "errcode": "ERR_ASSERTION",
      "extensions": {
        "messages": [],
        "exception": {
          "errcode": "ERR_ASSERTION"
        }
      },
      "message": "processNotice should not be set yet!",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "mnCreateIndividual"
      ],
      "stack": [
        "AssertionError [ERR_ASSERTION]: processNotice should not be set yet!",
        "    at registerNotifyListener (/Users/richard/repos/yp-postgraphile/node_modules/@graphile/operation-hooks/lib/PgNoticeMessagesPlugin.js:39:17)",
        "    at applyHooks (/Users/richard/repos/yp-postgraphile/node_modules/@graphile/operation-hooks/lib/OperationHooksCorePlugin.js:9:28)",
        "    at processTicksAndRejections (internal/process/task_queues.js:95:5)",
        "    at async resolve (/Users/richard/repos/yp-postgraphile/node_modules/@graphile/operation-hooks/lib/OperationHooksCorePlugin.js:116:38)",
        "    at async resolve (/Users/richard/repos/yp-postgraphile/node_modules/@graphile/operation-hooks/lib/OperationHooksCorePlugin.js:122:32)",
        "    at async /Users/richard/repos/yp-postgraphile/node_modules/postgraphile/build/postgraphile/withPostGraphileContext.js:165:24",
        "    at async withAuthenticatedPgClient (/Users/richard/repos/yp-postgraphile/node_modules/postgraphile/build/postgraphile/withPostGraphileContext.js:105:24)",
        "    at async /Users/richard/repos/yp-postgraphile/node_modules/postgraphile/build/postgraphile/http/createPostGraphileHttpRequestHandler.js:708:34",
        "    at async Promise.all (index 0)",
        "    at async graphqlRouteHandler (/Users/richard/repos/yp-postgraphile/node_modules/postgraphile/build/postgraphile/http/createPostGraphileHttpRequestHandler.js:640:23)"
      ]
    }
  ],
  "data": {
    "mnCreateIndividual": null
  },
  "explain": []
}

Additional context

I don't believe my setup is relevant, but here it is:

macOS 11.6
node v14.17.6 
"postgraphile": "^4.12.3"

Possible Solution

Someone who understands the purpose of the assertion should evaluate why this other plugin is causing a failure and then decide whether the test should be changed, or if one of the plugins has an issue that needs to be addressed.

Question about the format of callback's 'input' argument

I'm using the operation hooks plugin and I'm confused about the format of the input field of the callback.
This is what it looks like for my query:

{    
    data: [
      { '@nodes': { __identifiers: [1], username: 'alice' } },
      { '@nodes': { __identifiers: [2], username: 'bob' } }
    ],
    startCursor: null,
    endCursor: null
}

And this is the result of the same query that I get on the frontend:

{
    users: {
      nodes: [
        { username: 'alice', __typename: 'User' },
        { username: 'bob', __typename: 'User' }
      ],
      __typename: 'UsersConnection'
    }
}

I'm trying to get the input in the callback into the latter format, but have no idea where to start. What is the best way to go about this?

error when requiring library

On v0.2.2, require('@graphile/operation-hooks') will throw:
Cannot find module ./node_modules/@graphile/operation-hooks/lib/index.js. Please verify that the package.json has a valid "main" entry

Getting error "Default resolvers at the root level are not supported" following the custom subscriptions guide

Hello! I'm following the guide for custom subscriptions here: https://www.graphile.org/postgraphile/subscriptions/

Which results in the following error for me on server start: Error: Default resolver found for field Subscription.communityFeedItemCommentAdded; default resolvers at the root level are not supported by operation-hooks…

Here is my plugin:

const topicFromArgs = (args) => {
  return `graphql:communityFeedItemComments:${args.communityFeedItemId}`;
};

makeExtendSchemaPlugin(
  ({ pgSql: sql }) => ({
    typeDefs: gql`
      type CommunityFeedItemCommentSubscriptionPayload {
        communityFeedItemComment: CommunityFeedItemComment
        event: String
      }
      extend type Subscription {
        communityFeedItemCommentAdded(communityFeedItemId: Int!): CommunityFeedItemCommentSubscriptionPayload @pgSubscription(topic: ${embed(topicFromArgs)})
      }
    `,
    resolvers: {
      CommunityFeedItemCommentSubscriptionPayload: {
        async communityFeedItemComment(...args) {
          return require('./resolver').default(sql, ...args); // used to hot reload code in dev
        },
      },
    },
  }),
)
// resolver.js
export default async (
  sql,
  event,
  _args,
  _context,
  { graphile: { selectGraphQLResultFromTable } },
) => {
  const rows = await selectGraphQLResultFromTable(
    sql.fragment`public.community_feed_item_comment`,
    (tableAlias, sqlBuilder) => {
      sqlBuilder.where(
        sql.fragment`${tableAlias}.id = ${sql.value(event.subject)}`,
      );
    },
  );
  return {
    communityFeedItemComment: rows[0],
    event,
  };
};

If I explicitly create a resolver for communityFeedItemCommentAdded, then I think selectGraphQLResultFromTable doesn't look ahead nor does it collect the fields. Thus leading back to the above error.

Any ideas about how to resolve? Thanks!

SQL operation hooks should be per-table

Otherwise the risk of omitting one is too high. Custom mutations and extensions can handle their own pre/post events, so they don't really need it.

This will be a breaking change.

Allow messages to be registered from any SQL function

One of the great things about this plugin is that it allows pre/post mutation functions to "register" one or more notification messages that can be passed back to the GraphQL client. Crucially, these messages augment (not replace) the mutation response, which means that the mutation can still return what the client expects (e.g. the new entity in the case of an insert) PLUS any optional relevant notification messages.

It would be great if this convenience could be extended to ALL mutation functions. In particular, I'd like for any database function to be able to register one or more notification messages to an "ambient" transaction-specific context that could ultimately be read by PostGraphile (at the end of the request/response lifecycle, when generating the GraphQL response) to add those messages to the response, just like it currently does with the operation hooks functions.

Messages could be added via a universally accessible convenience function accepting the same four properties of the current notification type. For example, add_notification(type, message, code, path) would add a notification message to the "ambient" transaction-specific context.

Ideally, messages could also be checked via similar convenience functions, so that database functions may check if messages have been added by other functions. For example get_notifications() or get_notification(type) or even has_notification_error() could be used by a function to check if another called function added a notification of type error that should cause the transaction to abort or short-circuit.

Of course, there is the question of what an "ambient" transaction-specific context is, and how it would be implemented. By "ambient" I mean a context that can be accessed directly and from any function, that is, without having to pass it explicitly from function to function. In this sense, it would be similar to any global variable or settings, like the ones used to store and access the JWT claims. By transaction-specific, I mean that the context would apply to the currently running transaction only, with functions not having to worry about any transactional concerns.

As for how to implement it, there are several possibilities, but I'll leave that to the experts (I'm looking at you Benjie ;)

Irrespective of how it's implemented, an universal mechanism to register notifications from any function would be invaluable for complex applications that need to perform non-trivial business logic. It would allow functions to keep their signatures intact (no need to pass notifications messages back and forth explicitly) while still having the ability to return any relevant notifications to the GraphQL client.

Here's some potential implementations @benjie / @demianuco discussed:

Sending messages with LISTEN/NOTIFY

One possibility we've discussed is using PostgreSQL Listen/Notify, in which the add_notification() function would notify in a channel previously created (and subscribed to) by PostGraphile, which would "accumulate" notifications thus and ultimately append them to the GraphQL response at the end of the response life-cycle. There are two problems with this approach, however, one being that the payload is limited (shouldn't be a problem, as notification messages should be small), and the other being that the mechanism will not survive a transaction rollback, thus making it impossible to send errors back to the client (show stopper!).

Sending messages with RAISE NOTICE

Another possibility is to using the simpler RAISE NOTICE USING ERRCODE statement, which raises an indelible notice that is unaffected by rollbacks, and which PostGraphile can used to accumulate notification messages in a more prosaic way. It is a bit of a hack, in the sense that RAISE NOTICE was not meant for this, and the notification might pollute logs (though this could also be an advantage for development and debugging!). Also, it is perhaps fragile as it relies on the right level of verbosity being configured, with any changes to that verbosity breaking the functionality.

Sending messages via unlogged table

Another possibility is to use an explicit global table (e.g. tx_notifications) to which notification messages would be added. It would obviously have to be transactionally scoped, with a column storing the current transaction ID (perhaps select txid_current()?), but with the transactional concerns encapsulated away by the convenience functions, so that client functions could add notification messages without having to know about transactions. The table would have no limitations in terms of payload, but I'm concerned about performance and contention issues, especially because this table would be highly volatile and in constant and concurrent use. Perhaps the use of an unlogged table is appropriate in this scenario.

Logic error: operation hook returned 'undefined' with Byid queries

We are getting an error "Logic error: operation hook returned 'undefined'." when no record found with any "*ById" query on any table.

This error seems to be coming from "graphql/oprational-hooks".

Environment to reproduce the issue:

  • run the postgraphile with the following hooks:
    makePluginHook([OperationHooks, PgPubsub, subscriptionsLds]);
  • execute *ById query on any table passing non existing record *id to reproduce the error

Example to reproduce:

Table:

CREATE TABLE account ( "id" serial primary key, "firstName" text, "lastName" text, "dateOfBirth" date, "createdAt" timestamptz not null default now(), "updatedAt" timestamptz not null default now() )

Sample Request:

Note: Any non existing record id, example id = 10, a non existing record.

{ accountById(id: 10) { id } }

Sample Response:

{ "errors": [ { "message": "Logic error: operation hook returned 'undefined'.", "locations": [ { "line": 2, "column": 3 } ], "path": [ "accountById" ], "extensions": { "messages": [] } } ], "data": { "accountById": null } }

@benjie, Please let me know if you need any further information to reproduce the issue.

Achive cookie with the help of graphile/operation-hooks

Use case:

We have 2 Postgraphile, 1 for anonymous and another for authorized access.
Keycloak used is used as IAM. So a user created at keycloak can generate access_token and refresh_token.
For security purpose these tokens can`t be holded in UI. In anonymous there is a function authenticateUser, i tired to use graphile/operation-hooks to achieve this.

here are my codes.

server.js


const express = require("express");
const { postgraphile, makePluginHook } = require("postgraphile");
const app = express();
const cors = require('cors');
const cookieParser = require('cookie-parser');
const { OperationHooksPlugin } = require("@graphile/operation-hooks");
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
app.use(function(req, res, next) {
    next();
});
const additionalGraphQLContextFromRequest= (req, res) => {
    return {
         setCookie: function(access_token) {  res.cookie("access_token", 'Bearer ' + access_token,
          {                           
                 httpOnly: true,
                 SameSite: true,
               });
            },
         setRefreshCookie: function(refresh_token) {  res.cookie("refresh_token",   refresh_token)}       
    };
};

app.use(
    postgraphile(
        process.env.DATABASE_URL,
        process.env.SCHEMA.split(','),
        {                       
            additionalGraphQLContextFromRequest,           
            jwtSecret: "ecurewebtoken",
            jwtPgTypeIdentifier: "dcp_lib.jwt_token_postgraphile",
            watchPg: true,
            retryOnInitFail: true,
            graphiql: true,
            enhanceGraphiql: true,
            enableCors: false,
            disableWarning: true,
            operationMessages: true,
            operationMessagesPreflight: true,
            appendPlugins: [OperationHooksPlugin,require("@graphile-contrib/pg-simplify-inflector"), require("postgraphile-plugin-connection-filter"), require('./set-auth-cookie.js')],
            bodySizeLimit: "4MB"

        }
    )
);

app.listen(process.env.PORT);

set-auth-cookie.js


const useAuthCredentials = (build) => (fieldContext) => {
    const {
      scope: { isRootMutation, isPgCreateMutationField, pgFieldIntrospection }
    } = fieldContext;  
    if(!pgFieldIntrospection ||  
      pgFieldIntrospection.name !== "authenticateUser") {  
        return null;  
    }
  return {
    after: [
      {
        priority: 1000,
        callback: (result, args, context) => {        
          context.setRefreshCookie(JSON.parse(result.data).refresh_token);
          context.setCookie((JSON.parse(result.data).access_token));
        }
      }
    ]
  };
}
module.exports = function MyOperationHookPlugin(builder) {
  builder.hook("init", (_, build) => {
    build.addOperationHook(useAuthCredentials(build));
    return _;
  });
};

The error getting while calling authenticateUser

{
  "errors": [
    {
      "message": "Logic error: operation hook returned 'undefined'.",
      "locations": [
        {
          "line": 4,
          "column": 3
        }
      ],
      "path": [
        "authenticateUser"
      ],
      "extensions": {
        "messages": []
      }
    }
  ],
  "data": {
    "authenticateUser": null
  }
}

if there is any errors with my coding, please help.

Access RAISE NOTICE details from GraphQL client

Feature description

I've been using #16 to add out of band messages to my GraphQL mutations and it has been a blast! In my usage I employ detail = json_build_object(...) to add auxiliary data to my messages and I am able to access them in my operation hooks on the server.

However, while I'm able to access the messages part on my GraphQL client, I'm unable to access the detail part since it's not a part of OperationMessageInterface in the GraphQL schema.

Motivating example

Really any time you'd use detail as part of your notice and want to add auxiliary data. e.g. expanding on the minimal example in the README:

RAISE NOTICE 'Your credits are running low.' USING ERRCODE = 'OPMSG', detail = json_build_object('remaining_credits',5);

Breaking changes

This is only expanding the GraphQL schema, don't believe anything is breaking.

Supporting development

  • am interested in building this feature myself
    (would tick this if I thought I was capable of doing it myself 😅 )
  • am interested in collaborating on building this feature
  • am willing to help testing this feature before it's released
  • am willing to write a test-driven test suite for this feature (before it exists)
  • am a Graphile sponsor ❤️
  • have an active support or consultancy contract with Graphile

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.