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

Add GraphQL API #51

Merged
merged 45 commits into from
Dec 14, 2023
Merged

Add GraphQL API #51

merged 45 commits into from
Dec 14, 2023

Conversation

ingeniumed
Copy link
Contributor

@ingeniumed ingeniumed commented Nov 14, 2023

Description

This PR is meant add a GraphQL API to the Block Data API. It assumes that you have WP-GraphQL installed, and uses a filter to tweak the output of the ContentParser to generate the GraphQL response.

In addition, it uses the wp_unique_id function to generate ids for the blocks so that the blocks can be correlated together. The inner_blocks are therefore able to be flattened entirely, thereby bypassing the nested depth problem in graphQL.

This is in the draft status to get initial feedback, as well as to add some tests and guard rails around the GraphQL API.

graphql-rest-demo

Resolves #49

Steps to Test

  • Download, install and activate WP-GraphQL
  • Activate Block Data API with this branch
  • Modify the initial post to add some nested blocks
  • Run the following GraphQL query
query NewQuery {
  post(id: "1", idType: DATABASE_ID) {
    blocksData {
      blocks {
        id
        name
        attributes {
          name
          value
        }
        innerBlocks {
          name
          parentId
          id
          attributes {
            name
            value
          }
        }
      }
    }
  }
}
  • Ensure the output matches the post as well as the rest API

@ingeniumed ingeniumed self-assigned this Nov 14, 2023
@alecgeatches
Copy link
Contributor

@ingeniumed I noticed some errors in a response while testing today. In a post, I have this content:

<!-- wp:paragraph -->
<p>Unnested paragraph.</p>
<!-- /wp:paragraph -->

<!-- wp:columns -->
<div class="wp-block-columns"><!-- wp:column -->
<div class="wp-block-column"><!-- wp:paragraph -->
<p>Column 1</p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->

<!-- wp:column -->
<div class="wp-block-column"><!-- wp:paragraph -->
<p>Column 2</p>
<!-- /wp:paragraph --></div>
<!-- /wp:column -->

<!-- wp:column -->
<div class="wp-block-column"><!-- wp:paragraph -->
<p>Column 3</p>
<!-- /wp:paragraph --></div>
<!-- /wp:column --></div>
<!-- /wp:columns -->

image


And then I query for the blocks/innerBlocks of that post:

query NewQuery {
  post(id: 419, idType: DATABASE_ID) {
    blocksData {
      blocks {
        attributes {
          name
          value
        }
        id
        name
        innerBlocks {
          attributes {
            name
            value
          }
          id
          name
          parentId
        }
      }
    }
  }
}

The result appears to hold the correct blocks, but also a bunch of Internal server error in an errors array:

Click to expand
{
  "errors": [
    {
      "message": "Internal server error",
      "extensions": {
        "category": "internal"
      },
      "locations": [
        {
          "line": 12,
          "column": 11
        }
      ],
      "path": [
        "post",
        "blocksData",
        "blocks",
        1,
        "innerBlocks",
        0,
        "attributes"
      ]
    },
    {
      "message": "Internal server error",
      "extensions": {
        "category": "internal"
      },
      "locations": [
        {
          "line": 12,
          "column": 11
        }
      ],
      "path": [
        "post",
        "blocksData",
        "blocks",
        1,
        "innerBlocks",
        2,
        "attributes"
      ]
    },
    {
      "message": "Internal server error",
      "extensions": {
        "category": "internal"
      },
      "locations": [
        {
          "line": 12,
          "column": 11
        }
      ],
      "path": [
        "post",
        "blocksData",
        "blocks",
        1,
        "innerBlocks",
        4,
        "attributes"
      ]
    }
  ],
  "data": {
    "post": {
      "blocksData": {
        "blocks": [
          {
            "attributes": [
              {
                "name": "content",
                "value": "Unnested paragraph."
              },
              {
                "name": "dropCap",
                "value": ""
              }
            ],
            "id": "1",
            "name": "core/paragraph",
            "innerBlocks": null
          },
          {
            "attributes": [
              {
                "name": "isStackedOnMobile",
                "value": "1"
              }
            ],
            "id": "2",
            "name": "core/columns",
            "innerBlocks": [
              {
                "attributes": null,
                "id": "3",
                "name": "core/column",
                "parentId": "2"
              },
              {
                "attributes": [
                  {
                    "name": "content",
                    "value": "Column 1"
                  },
                  {
                    "name": "dropCap",
                    "value": ""
                  }
                ],
                "id": "4",
                "name": "core/paragraph",
                "parentId": "3"
              },
              {
                "attributes": null,
                "id": "5",
                "name": "core/column",
                "parentId": "2"
              },
              {
                "attributes": [
                  {
                    "name": "content",
                    "value": "Column 2"
                  },
                  {
                    "name": "dropCap",
                    "value": ""
                  }
                ],
                "id": "6",
                "name": "core/paragraph",
                "parentId": "5"
              },
              {
                "attributes": null,
                "id": "7",
                "name": "core/column",
                "parentId": "2"
              },
              {
                "attributes": [
                  {
                    "name": "content",
                    "value": "Column 3"
                  },
                  {
                    "name": "dropCap",
                    "value": ""
                  }
                ],
                "id": "8",
                "name": "core/paragraph",
                "parentId": "7"
              }
            ]
          }
        ]
      }
    }
  },
  "extensions": {
    "debug": [
      {
        "type": "DEBUG_LOGS_INACTIVE",
        "message": "GraphQL Debug logging is not active. To see debug logs, GRAPHQL_DEBUG must be enabled."
      }
    ]
  }
}

README.md Outdated
Comment on lines 1028 to 1036
```php
/**
* Filter to enable/disable the graphQL API. By default, it is enabled.
*
* @param bool $is_graphql_to_be_enabled Whether the graphQL API should be enabled or not.
*/
apply_filters( 'vip_block_data_api__is_graphql_enabled', true );
```

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a little odd to include the apply_filters definition here, which isn't relevant to usage. I would remove it

Suggested change
```php
/**
* Filter to enable/disable the graphQL API. By default, it is enabled.
*
* @param bool $is_graphql_to_be_enabled Whether the graphQL API should be enabled or not.
*/
apply_filters( 'vip_block_data_api__is_graphql_enabled', true );
```

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

README.md Outdated
Comment on lines 1040 to 1042
add_filter( 'vip_block_data_api__is_graphql_enabled', function( ) {
return false;
}, 10, 1);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
add_filter( 'vip_block_data_api__is_graphql_enabled', function( ) {
return false;
}, 10, 1);
add_filter( 'vip_block_data_api__is_graphql_enabled', '__return_false' );

return new \Exception( $parser_results->get_error_message() );
}

$parser_results['blocks'] = array_map(function ( $block ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there no phpcs on this repo?

Suggested change
$parser_results['blocks'] = array_map(function ( $block ) {
$parser_results['blocks'] = array_map( function ( $block ) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh, weird! We do have phpcs configured, and it seems to spot other problems in this file just fine, but it's not enforcing parentheses spacing. Our phpcs configuration is a close relation to vip-go-mu-plugins's config which appears to be roughly the same. Looking into what sniff might be missing here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Figured it out. I noticed if I add a missing space a couple of lines up, PHPCS will report it:

$block['id'] = Relay::toGlobalId('ID', wp_unique_id() );
//                               ^ missing space
FILE: src/graphql/graphql-api.php
--------------------------------------------------------------------------
FOUND 1 ERROR AFFECTING 1 LINE
--------------------------------------------------------------------------
 73 | ERROR | [x] Expected 1 spaces after opening parenthesis; 0 found
    |       |     (PEAR.Functions.FunctionCallSignature.SpaceAfterOpenBracket)

However, that same PEAR.Functions.FunctionCallSignature.SpaceAfterOpenBracket sniff doesn't kick in for array_map(). After looking at our configuration, I noticed we have some sniffs disabled related to anonymous functions and function calls. If I remove those, we'll now get multiple errors for the multi-line function call:

 72 | ERROR | [x] Opening parenthesis of a multi-line function call must be the last content on the line
    |       |     (PEAR.Functions.FunctionCallSignature.ContentAfterOpenBracket)
 74 | ERROR | [x] Only one argument is allowed per line in a multi-line function call (PEAR.Functions.FunctionCallSignature.MultipleArguments)
 74 | ERROR | [x] Closing parenthesis of a multi-line function call must be on a line by itself (PEAR.Functions.FunctionCallSignature.CloseBracketLine)

So, in short, if we want to disable those sniffs to use some standard PHP array_map( function( ... ) { practices, we also lose the sniff that tells us about weird spacing after array_map().

Fixed manually.

/**
* Transform the block's format to the format expected by the graphQL API.
*
* @param array $block An associative array of parsed block data with keys 'name' and 'attribute'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for type declarations instead of hand-written descriptions

Suggested change
* @param array $block An associative array of parsed block data with keys 'name' and 'attribute'.
* @param array $block An associative array of parsed block data with keys 'name' and 'attributes'.

/**
* Convert the attributes to be in the name-value format that the schema expects.
*
* @param array $block An associative array of parsed block data with keys 'name' and 'attribute'.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* @param array $block An associative array of parsed block data with keys 'name' and 'attribute'.
* @param array $block An associative array of parsed block data with keys 'name' and 'attributes'.

Comment on lines +88 to +90
// check if type of attributes is stdClass and unset it as that's not supported by graphQL.
if ( isset( $block['attributes'] ) && is_object( $block['attributes'] ) ) {
unset( $block['attributes'] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how / why would this happen?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ingeniumed Can you explain a bit more about the stdClass comment here? I'm not sure where that comes from either.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So this is because in the contentParser we set the actual attributes to be (object)[] which allows it to be given back as empty json in the rest api rather than as an empty array. Due to that, the internal data type comes back as stdClass rather than an array. This workaround is due to that.

I experimented with switching the (object)[] to a new ArrayObject but in that case it adds an empty array with key storage under the attributes which requires an even worse workaround. That's why it's been done this way.

I tried to search for a better solution but couldn't quite find one and I didn't want to break the rest response or the way the public function works because there are customers already using it.

This is the line I'm referencing btw in the contentParser.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hopefully that explains it properly

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ingeniumed That sounds familiar now! As a side-effect of returning data in JSON via the REST API, if we want to return attributes: {} in a response, we need to cast [] into an object to avoid returning an array instead. This is just dealing with that unusual data in the GraphQL layer.

function ( $name, $value ) {
return [
'name' => $name,
'value' => $value,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess WPGraphQL is doing the casting to string? You might want to do it here in code just to be explicit and test it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess WPGraphQL is doing the casting to string?

I think you're right! I tested with a block delimiter value like {"test": 123}, which is returned as an integer from the parser and REST API. However, once we return it in a BlockAttribute, it is converted to a string. Added explicit conversion in 8cdc1e2 anyway.

Comment on lines 118 to 121
// Set the parentId to be the ID of the parent block whose inner blocks are being flattened.
$inner_block['parentId'] = $parent_id;

// This block doesnt have any inner blocks, so just add it to the flattened blocks. Ensure the parentId is set.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

indenting is off here

],
'parentId' => [
'type' => 'ID',
'description' => 'ID of the parent for this inner block, if it is an inner block. Otherwise, it will not be set. If it set, this will match the ID of the block',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

last part confusing / redundant

Suggested change
'description' => 'ID of the parent for this inner block, if it is an inner block. Otherwise, it will not be set. If it set, this will match the ID of the block',
'description' => 'ID of the parent for this inner block, if it is an inner block. Otherwise, it will be null.',

'args' => [
'flatten' => [
'type' => 'Boolean',
'description' => 'Collate the inner blocks under each root block into a single list with a parent-child relationship. This is set to true by default, and setting it to false will reverse that to preserve the original block hierarchy, at the expense of knowing the exact depth when querying the inner blocks. Default: true',
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'description' => 'Collate the inner blocks under each root block into a single list with a parent-child relationship. This is set to true by default, and setting it to false will reverse that to preserve the original block hierarchy, at the expense of knowing the exact depth when querying the inner blocks. Default: true',
'description' => 'Collate the inner blocks under each root block into a single list with a parent-child relationship. This is set to true by default, and setting it to false will preserve the original block hierarchy, but will require nested inner block queries to the desired depth. Default: true',

],
],
'resolve' => function ( $blocks, $args ) {
if ( ! isset( $args['flatten'] ) || true === $args['unflatten'] ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug, probably needs tests

Suggested change
if ( ! isset( $args['flatten'] ) || true === $args['unflatten'] ) {
if ( ! isset( $args['flatten'] ) || false === $args['flatten'] ) {

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! I fixed this, tested it manually, and put it in d578fe4. I spent some time trying to add a test, but after my attempt I think @ingeniumed has already done a decent job trying to wrap the core logic from the resolver with a test:

'resolve' => function ( $blocks, $args ) {
if ( ! isset( $args['flatten'] ) || true === $args['flatten'] ) {
$blocks['blocks'] = array_map( function ( $block ) {
// Flatten the inner blocks, if any.
if ( isset( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = self::flatten_inner_blocks( $block['innerBlocks'], $block['id'] );
}
return $block;
}, $blocks['blocks'] );
}
return $blocks['blocks'];
},

Our unit testing does not include the WPGraphQL plugin, so the logic that's in this resolver isn't directly tested in test_flatten_inner_blocks(). The bug here is in the thin shell of WPGraphQL code around the flatten_inner_blocks() function, which we test directly. Ideally we could have some end-to-end tests that include the request through WPGraphQL (like we do for our REST tests), but I think the testing here is a good enough start. As long as we catch the interface bugs, like this.

Comment on lines 221 to 228
$blocks['blocks'] = array_map(function ( $block ) {
// Flatten the inner blocks, if any.
if ( isset( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = self::flatten_inner_blocks( $block['innerBlocks'], $block['id'] );
}

return $block;
}, $blocks['blocks'] );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just do this, no?

Suggested change
$blocks['blocks'] = array_map(function ( $block ) {
// Flatten the inner blocks, if any.
if ( isset( $block['innerBlocks'] ) ) {
$block['innerBlocks'] = self::flatten_inner_blocks( $block['innerBlocks'], $block['id'] );
}
return $block;
}, $blocks['blocks'] );
// Flatten the inner blocks, if any.
$blocks['blocks'] = self::flatten_inner_blocks( $blocks['blocks'], null );

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replacement doesn't work as-is, because flatten_inner_blocks() needs the $parent_id parameter, which is passed to innerBlocks to determine their parent IDs:

public static function flatten_inner_blocks( $inner_blocks, $parent_id, $flattened_blocks = [] ) {
foreach ( $inner_blocks as $inner_block ) {
// Set the parentId to be the ID of the parent block whose inner blocks are being flattened.
$inner_block['parentId'] = $parent_id;

I think this is due to the design that top level blocks are treated differently as "root" blocks, but their children are flattened beneath them all together. I'm sure it's possible to rewrite flatten_inner_blocks() so this is done internally, but I think this works fine.

Copy link
Contributor

@alecgeatches alecgeatches Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait a minute, I'm rereading this and I'm not sure why it's not working anymore. Let me take a second look.

@ingeniumed ingeniumed linked an issue Dec 10, 2023 that may be closed by this pull request
@alecgeatches alecgeatches self-assigned this Dec 13, 2023
@alecgeatches
Copy link
Contributor

@chriszarate Thanks a ton for your review! I believe I've addressed all of your suggestions above. Please take another look when you have a chance. Thank you!

*/
public static function transform_block_format( $block ) {
// Generate a unique ID for the block.
$block['id'] = Relay::toGlobalId( 'ID', wp_unique_id() );
Copy link
Member

@chriszarate chriszarate Dec 13, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The first argument to this function should the type name of the object in question, so BlockData in this case. This is what ensures uniqueness across the graph. Also, since wp_unique_id just increments integers, you should probably also incorporate the post ID. Otherwise, you risk collisions in client caches which will be really hard to diagnose. (I've seen this happen and it's bewildering when a paragraph from post 2 shows up in post 1).

Suggested change
$block['id'] = Relay::toGlobalId( 'ID', wp_unique_id() );
$block['id'] = Relay::toGlobalId( 'BlockData', sprintf( "%d:%d", $post->ID, wp_unique_id() ) );

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for clarifying this @chriszarate! Changed in 6e2c367.

Copy link
Member

@chriszarate chriszarate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All my feedback has been addressed, but I'll leave it to Bistro to approve :)

@ingeniumed
Copy link
Contributor Author

I'll merge this into our release branch, and we can cut a release later this week via that. Thanks for the amazing feedback @chriszarate, and @smithjw1.

@ingeniumed ingeniumed merged commit 76b143b into planned-release/1.1.0 Dec 14, 2023
2 checks passed
@ingeniumed ingeniumed deleted the add/graphql-api branch December 14, 2023 02:14
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.

Add in WPGraphQL support
4 participants