Problem/Motivation

JSON API sister issue of #1927648: Allow creation of file entities from binary data via REST requests.

Per https://github.com/json-api/json-api/issues/246, the JSON API spec has not yet prescribed how this should be handled. That issue has been open for nearly 4 years. Last feedback from the spec maintainers on Feb 24, 2017. They're open to make a formal recommendation in the spec based on input. I think we can relay Drupal's experience implementing #1927648 in Drupal core and the trade-offs that were considered there. See https://github.com/json-api/json-api/issues/246#issuecomment-282192434.

Proposed resolution

Flow 1 (most cases)

Request:

POST /jsonapi/entity_type/bundle/{uuid}/file_field

Content-Type: application/octet-stream
Accept: application/vnd.api+json

// binary data

Response:

200 /jsonapi/entity_type/bundle/{uuid}/file_field

Content-Type: application/vnd.api+json

// file entity

Flow 2 (creating a new entity with a required file field)

Request:

POST /jsonapi/entity_type/bundle/file_field

Content-Type: application/octet-stream
Accept: application/vnd.api+json

// binary data

Response:

200 /jsonapi/entity_type/bundle/file_field

Content-Type: application/vnd.api+json

// file entity

2nd Request:

POST /jsonapi/entity_type/bundle

Content-Type: application/vnd.api+json

{
  // data with relationship to newly created file entity
}

Remaining tasks

  1. Port core's test coverage. Done, see #74. This covers "Flow #2".
  2. Test coverage for "Flow #1"
  3. Prove that the shim works in core's rest.module too

User interface changes

None.

API changes

None.

Data model changes

None.

CommentFileSizeAuthor
#164 2958554-164-followup_2940383-23.patch6.48 KBwim leers
#161 interdiff.txt518 byteswim leers
#158 2958554-158.patch76.03 KBwim leers
#156 2958554-156.patch75.25 KBwim leers
#156 interdiff.txt6.1 KBwim leers
#139 2958554-139.patch79.97 KBgabesullice
#139 interdiff.txt7.4 KBgabesullice
#138 2958554-138.patch77 KBgabesullice
#138 interdiff.txt2.58 KBgabesullice
#130 2958554-130.patch76.77 KBandriansyah
#129 2018-12-24_1643.png34.69 KBandriansyah
#124 2018-12-07_17-46-22.png111.7 KBblainelang
#119 2958554-119.patch76.73 KBgabesullice
#119 interdiff.txt2.1 KBgabesullice
#119 interdiff-corrected-correction.txt1.98 KBgabesullice
#117 2958554-117.patch76.73 KBgabesullice
#117 interdiff.txt11.53 KBgabesullice
#116 2958554-116.patch77.43 KBwim leers
#116 interdiff.txt567 byteswim leers
#114 2958554-114.patch77.08 KBwim leers
#114 interdiff.txt4.96 KBwim leers
#113 2958554-113.patch77.43 KBwim leers
#113 interdiff.txt1.68 KBwim leers
#113 interdiff-wrong_attempt.txt3.23 KBwim leers
#108 2958554-108.patch76.22 KBwim leers
#108 interdiff.txt627 byteswim leers
#106 2958554-106.patch76.28 KBwim leers
#106 interdiff.txt22 KBwim leers
#102 2958554-101.patch79.08 KBwim leers
#102 interdiff.txt1.2 KBwim leers
#101 2958554-100.patch79.01 KBwim leers
#101 interdiff.txt7.51 KBwim leers
#97 2958554-97.patch74.7 KBwim leers
#97 interdiff.txt6.36 KBwim leers
#95 2958554-95.patch73.57 KBwim leers
#95 interdiff.txt4.13 KBwim leers
#84 2958554-84.patch72.95 KBwim leers
#80 2958554-80--including_2987608-28_and_2987610-28.patch72.96 KBwim leers
#79 2958554-79.patch72.96 KBwim leers
#79 interdiff.txt541 byteswim leers
#77 interdiff.txt636 byteswim leers
#77 2958554-77.patch72.93 KBwim leers
#74 2958554-74.patch72.9 KBwim leers
#74 diff_rest_vs_jsonapi_test.txt32.33 KBwim leers
#74 interdiff-logic_fix.txt1.31 KBwim leers
#74 interdiff-tests.txt26.1 KBwim leers
#73 2958554-73.patch46.53 KBwim leers
#73 interdiff.txt1.33 KBwim leers
#72 jsonapi-file_creation-2958554-72.patch47.48 KBgrimreaper
#60 jsonapi-file_creation-2958554-60.patch45.59 KBpfrilling
#60 interdiff_54_60.txt2.33 KBpfrilling
#28 2958554-28.patch26.97 KBmalik.kotob
#28 interdiff-2958554-24.txt9.84 KBmalik.kotob
#24 2958554-24.patch19.26 KBmalik.kotob
#24 interdiff-2958554-23.txt469 bytesmalik.kotob
#23 2958554-23.patch19.28 KBgabesullice
#23 interdiff-2958554-22.txt5.05 KBgabesullice
#22 2958554-22.patch19.46 KBgabesullice
#22 interdiff-2958554-21.txt40.57 KBgabesullice
#21 2958554-21.patch22 KBmalik.kotob
#20 2958554-20.patch20.65 KBmalik.kotob
#17 2958554-17.patch18.66 KBmalik.kotob
#29 2958554-29.patch32.83 KBmalik.kotob
#29 interdiff-2958554-28.txt10.42 KBmalik.kotob
#31 interdiff-2958554-29.txt8.22 KBmalik.kotob
#31 2958554-31.patch32.96 KBmalik.kotob
#32 2958554-32.patch32.96 KBmalik.kotob
#32 interdiff-2958554-31.txt1.21 KBmalik.kotob
#33 2958554-33.patch33.15 KBgabesullice
#34 interdiff-33-34.txt21.84 KBgabesullice
#34 2958554-34.patch41.78 KBgabesullice
#35 interdiff-33-34.txt11.54 KBgabesullice
#36 interdiff-34-36.txt3.49 KBgabesullice
#36 2958554-36.patch41.65 KBgabesullice
#37 interdiff-36-37.patch8.64 KBgabesullice
#37 2958554-37.patch44.25 KBgabesullice
#38 2958554-38.patch44.75 KBgabesullice
#39 interdiff-38-39.txt5.49 KBgabesullice
#39 2958554-39.patch45.71 KBgabesullice
#40 interdiff-39-40.txt1.29 KBgabesullice
#40 2958554-40.patch45.65 KBgabesullice
#45 jsonapi-file_creation-2958554-45.patch46.92 KBgrimreaper
#52 jsonapi-file_creation-2958554-52.patch45.16 KBpfrilling
#52 interdiff_45_52.txt615 bytespfrilling
#54 interdiff_52_54.txt1.03 KBpfrilling
#54 jsonapi-file_creation-2958554-54.patch45.26 KBpfrilling

Comments

Wim Leers created an issue. See original summary.

wim leers’s picture

Version: 8.x-1.x-dev » 8.x-2.x-dev

This will certainly be a 2.x-only feature.

gabesullice’s picture

I think this should actually live in a separate module, not JSON API itself.

I have a 2 proposed flows. The first takes advantage of JSON API and its concept of relationships routes which gets us a DX improvement over core REST, the second is a fallback for the case of required file fields which takes us back to core REST DX. Both can live side-by-side.

For an existing resource:

Request:

POST /jsonapi/entity_type/bundle/{uuid}/relationships/file_field

Content-Type: application/octet-stream
Accept: application/vnd.api+json

// binary data

Response:

200 /jsonapi/entity_type/bundle/{uuid}/relationships/file_field

Content-Type: application/vnd.api+json

// file entity

The above would only work for a resource which already exists and has an image field. The advantage over core REST with the above is that we can automatically upload the file, create the file entity and reference the file entity all in one request!

This of course will not work for creating resources with a required file field. In that case, I propose to fall back to the following flow:

Request:

POST /jsonapi/file/file

Content-Type: application/octet-stream
Accept: application/vnd.api+json

// binary data

Response:

200 /jsonapi/file/file

Content-Type: application/vnd.api+json

// file entity

2nd Request:

POST /jsonapi/entity_type/bundle

Content-Type: application/vnd.api+json

{
  // data with relationship to newly created file entity
}
wim leers’s picture

I think this should actually live in a separate module, not JSON API itself.

Maybe, but not convinced about that just yet.

The above would only work for a resource which already exists and has an image field. The advantage over core REST with the above is that we can automatically upload the file, create the file entity and reference the file entity all in one request!

😍😍😍😍😍😍😍😍

This of course will not work for creating resources with a required file field. In that case, I propose to fall back to the following flow:

Alternative proposal: make it impossible to make file fields required instead 🙈 j/k of course…

POST /jsonapi/file/file

This does not work because it doesn't allow for validating the file before uploading it.

gabesullice’s picture

Maybe, but not convinced about that just yet.

This would be yet another extension to the spec that we'd be putting in the module without a negotiation or advertisement mechanism. That's why I said it should be separate. I would be willing to be convinced otherwise.

This does not work because it doesn't allow for validating the file before uploading it.

Darn! It was so elegant. I think we're still fine though. /jsonapi/file/file becomes /jsonapi/entity_type/bundle/file_field. The spec is silent about URLs, so this is okay.

gabesullice’s picture

Issue summary: View changes

Updated the IS with the proposal.

gabesullice’s picture

Issue summary: View changes

Updating the 1st flow paths from /jsonapi/entity_type/bundle/relationships/file_field to /jsonapi/entity_type/bundle/file_field because the spec enforces that the documents to and from relationships must contain only resource identifiers not resource objects.

wim leers’s picture

That's why I said it should be separate. I would be willing to be convinced otherwise.

You've convinced me :) Once it's in the spec (or in a spec), we could deprecate that module and eventually make it available by default in jsonapi itself.

Thanks for that excellent issue summary!

ebeyrent’s picture

FYI, there's already the JSON API File module: https://www.drupal.org/project/jsonapi_file

Might be a good chance to pull in the code from that module and deprecate it.

wim leers’s picture

Thanks for making that connection :)

Unfortunately that module only allows base64-encoded uploads and requires the https://www.drupal.org/project/file_entity module. It cannot be reused as-is though: it needs a significant rework to not rely on base64 encoding. It's going to be much easier to start from #1927648: Allow creation of file entities from binary data via REST requests.
We're talking to the maintainer about this! :)

wim leers’s picture

Now that the 2.x branch of JSON API has actually started, and we're trying to get JSON API into core, it'd be great if we could get this done too.

e0ipso’s picture

+1

malik.kotob’s picture

Should this be postponed until #2940383: [META] Unify file upload logic of REST and JSON:API lands, which is now active?

gabesullice’s picture

Should this be postponed until https://www.drupal.org/project/drupal/issues/2940383 lands, which is now active?

As much as that makes sense, I don't trust the core process to get that service in, in time. So, I'd be fine if this issue remained open and we created our own, temporary service.

malik.kotob’s picture

@gabesullice so JSON API would create its own temporary service, and simply swap it out for the one #2940383: [META] Unify file upload logic of REST and JSON:API provides, once it is completed? @e0ipso or @gabesullice, do we know what kind of timeline JSON API is working with to try to close this out? Trying to make a decision on how to proceed with supporting remote file upload for a client site since both #2983404: [PP-1] RESTs FileUploadResource plugin is only able to check `create` access to a parent entity, should be able to check `edit` also and #2940383: [META] Unify file upload logic of REST and JSON:API are outstanding. If there are no plans to fix this issue in the next few weeks, I can either:

1. Attempt to write a custom rest plugin of my own that essentially copies what was done in #2940383: [META] Unify file upload logic of REST and JSON:API, but takes in the entity as a parameter. This may not be a very big lift, but would duplicate core's code.

2. Leverage the jsonapi_file module. My understanding is that this route is not desirable since that module will become obsolete once the current issue is fixed, and it introduces a dependency on the file_entity module.

What're your thoughts on the best route to take?

gabesullice’s picture

@gabesullice so JSON API would create its own temporary service, and simply swap it out for the one #2940383: Create "file upload" service that core REST, JSON API and GraphQL can all use provides, once it is completed?

That's my hope!

@e0ipso or @gabesullice, do we know what kind of timeline JSON API is working with to try to close this out?

This is high on my list, and @Wim Leers' too... but you could make it higher! If you post a patch, I can almost guarantee you'll have reviews from either @Wim Leers or I within a business day :)

I would say that you should do something like #1, but instead, make the service we need out of the existing plugin :P If this isn't committed in time for you, you can still pretty trivially make a REST plugin of your own that will wrap it. You'll be no worse off.

The only way I think that this doesn't land in JSON API within a few weeks is if there's a specification incompatibility that we can't work around. But in THAT case, we'll just move the code to a contrib module (maybe jsonapi_file 2.x), and you'll still be good.

So, starting that service is definitely the best way forward.

malik.kotob’s picture

StatusFileSize
new18.66 KB

Alrighty @gabesullice, this patch introduces the temporary service to JSON API but doesn't actually create the necessary endpoints. It might be quicker for you or @Wim Leers to take on that part as opposed to providing me guidance on how to do it. Feel free to provide feedback on what should change with the service.

P.S. Since it doesn't actually introduce the JSON API endpoints, I actually hacked core's file upload rest resource plugin to point to this service to test it and was able to upload a file successfully.

gabesullice’s picture

Status: Active » Needs review

Thanks @malik.kotob! This is a great start!

Here's my very rough, "first reaction" review:

I could be wrong about lots of things below :P

  1. +++ b/jsonapi.services.yml
    @@ -136,6 +136,9 @@ services:
    +  jsonapi.file.upload:
    

    Let's mark this service as "private" with: public: false

  2. +++ b/jsonapi.services.yml
    @@ -136,6 +136,9 @@ services:
    +    class: Drupal\jsonapi\FileUploadHandler
    

    We should probably change this from "Handler" to something else. "Handler" has a specific meaning WRT to entities.

    Not sure what yet though.

  3. +++ b/src/FileUploadHandler.php
    @@ -0,0 +1,491 @@
    +namespace Drupal\jsonapi;
    

    I wonder if we should add a namespace like Drupal\jsonapi\Shim.

  4. +++ b/src/FileUploadHandler.php
    @@ -0,0 +1,491 @@
    +class FileUploadHandler {
    

    Needs docblock and to be marked @internal

    Perhaps we need an interface? That could be added later though.

  5. +++ b/src/FileUploadHandler.php
    @@ -0,0 +1,491 @@
    +  public function handleUploadForField($entity_type_id, $bundle, $field_name, $filename) {
    ...
    +  protected function streamUploadData() {
    +    // 'rb' is needed so reading works correctly on Windows environments too.
    +    $file_data = fopen('php://input', 'rb');
    

    I think this method would be more useful if it accepted a stream
    like the one fopen() returns.

    Then handleUploadForField would become handleUploadForField($entity_type_id, $bundle, $field_name, $filename, $stream)

  6. +++ b/src/FileUploadHandler.php
    @@ -0,0 +1,491 @@
    +    $access_result = $entity_access_control_handler->createAccess($bundle, NULL, [], TRUE)
    +      ->andIf($entity_access_control_handler->fieldAccess('edit', $field_definition, NULL, NULL, TRUE));
    

    What do we need to do to this service to handle the case of an existing parent entity?

e0ipso’s picture

I love seeing progress on this issue!

malik.kotob’s picture

StatusFileSize
new20.65 KB

1. Done
2. I changed it to FileUploadService, what do you think?
3. Done (I think that makes sense)
4. Done (with FileUploadServiceInterface created as well)
5. Done (not sure if I implemented it as you envisioned though)
6. I was brainstorming a couple of solutions here:

a) add the entity as a parameter, if it’s present, check access at the field level (that doesn’t change), and also check access against the entity via EntityAccessControlHandler::access(), passing along the entity and the operation (update), if it’s not present, continue to check access using EntityAccessControlHandler::createAccess() and the field-level check

b) add a parameter that specifies whether or not to perform an access check, if the value is true, we’ll do the check as is, if its not true (which would be how jsonapi/rest/graphql are more likely to use it), they can perform the access check themselves. The resource specific to files being created against new entities would likely do the access check against createAccess, and the resource specific to files being created against existing entities would do the check against access

What do you think?

malik.kotob’s picture

StatusFileSize
new22 KB

Here's a patch with some fix ups after I tested (again, using core's file upload rest resource plugin). I also went ahead and incorporated option 6a here just to get something going. With it, I was able to successfully upload a file against the current user entity.

gabesullice’s picture

StatusFileSize
new40.57 KB
new19.46 KB

This is awesome :)

Some initial thoughts:

  1. +++ b/src/Shim/FileUploadService.php
    @@ -0,0 +1,509 @@
    +  public function handleUploadForField($entity_type_id, $bundle, $field_name, $entity = NULL, $filename, $stream = NULL) {
    

    Let's make this a lot simpler, I think it can just receive FieldDefinitionInterface $field_definition, $filename, $stream with no optional params.

    JSON API, REST and GraphQL can do all the figuring out WRT to which field definition and how to check access.

    This is just a file upload handler after all.

  2. +++ b/src/Shim/FileUploadService.php
    @@ -0,0 +1,509 @@
    +  protected function streamUploadData($stream = NULL) {
    

    Let's rename this to something like readFileData($stream) (no optional param).

  3. +++ b/src/Shim/FileUploadService.php
    @@ -0,0 +1,509 @@
    +    $file_data = isset($stream) ? $stream : fopen('php://input', 'rb');
    

    Instead of the optional parameter, let's add a new public static method on the service: getUploadStream() which returns fopen('php://input', 'rb').


I went ahead and took those thoughts and put them into code.

Mainly, I wanted the file uploader service to just have a single method that does just what it's named. Convenience functions for access checking and validation etc are not on the interface and are public+static, so they can be used even if someone swaps out the upload handler (that should make it easier to handle uploads to s3, for instance).

Let me know what you think.


More thoughts:

We might want to get rid of HTTP-specific exceptions. For example, we could change UnprocessableEntityHttpException into an InvalidArgumentException (since the $file argument failed validation). If this is a generic file upload handler, then I think we need to let JSON API/REST/GraphQL map those exceptions into whatever makes sense in context. I'm not convinced of that though.

The more I think about this, the more I think it belongs in a submodule, not the main module.

gabesullice’s picture

StatusFileSize
new5.05 KB
new19.28 KB

I didn't like the owner of the file being optional, I don't think that makes much sense.

One thing I didn't document and is not clear in the previous interdiff is that I removed calls to fclose($file_data). Since we're passing that in, I think it's the responsibility of the caller to close the stream when it's done with it.


+++ /dev/null
@@ -1,509 +0,0 @@
-          fclose($file_data);
...
-          fclose($file_data);
...
-      fclose($file_data);
...
-    // Close the input stream.
-    fclose($file_data);

+++ b/src/Shim/FileUploader.php
@@ -0,0 +1,458 @@
+  /**
+   * Gets a standard file upload stream.
+   *
+   * @return resource
+   *   A stream ready to be read. Do not forget to close the stream after use
+   *   with fclose().
+   */
+  public static function getUploadStream() {
malik.kotob’s picture

StatusFileSize
new469 bytes
new19.26 KB

Awesome work @gabesullice!

RE #22:

1. This makes sense to me, good call
2. Makes sense
3. Makes sense


We might want to get rid of HTTP-specific exceptions. For example, we could change UnprocessableEntityHttpException into an InvalidArgumentException (since the $file argument failed validation). If this is a generic file upload handler, then I think we need to let JSON API/REST/GraphQL map those exceptions into whatever makes sense in context. I'm not convinced of that though.

This actually makes sense to me, but it seems like it may be a good fit to perform that refactoring in #2940383: [META] Unify file upload logic of REST and JSON:API.


RE #23:

I agree on both of these, both good catches!


Separately, I found a minor leftover "optional" comment in the doc block for a parameter on a function. Went ahead and a attached a new patch that has that fix.


How do we move forward from here? I think what's left is:
1. Deciding if this needs to get moved into another module
2. Adding in the two jsonapi endpoints that actually invoke this service

Is there anything else?

gabesullice’s picture

Good catch!


How do we move forward from here? I think what's left is:
1. Deciding if this needs to get moved into another module
2. Adding in the two jsonapi endpoints that actually invoke this service

1. Given #3 + #5 + #8, which I had forgotten about, I think this needs to be in a submodule.
2. #3 + #5 describes what I think would be the ideal set of routes for this. @malik.kotob do you want to try that, or would you rather @Wim Leers or I take that on?

Is there anything else?

Not that I can think of off the top of my head.

I'm working on #2953346: Define related/relationship routes per field, not dynamically (with route parameters that need validating) ATM. That will change how relationship field access is handled and will make access checking here even easier. So I think we can do everything except access handling for now.

malik.kotob’s picture

Okay great, @gabesullice! If you're able to provide some guidance/best practices on how to define the routes and make it compliant with the spec, I can take a crack at this.

gabesullice’s picture

WRT to spec compliance, there's not a lot to do... because the spec doesn't define anything about file uploads :P

But, #3 mentions a few request and response headers that I think we should use. Those are pretty close in spirit to the spec.

For the response to the upload request, after you've created/saved the file entity in code, I'd make a subrequest for the file entity, then just update the self link in the response.

To get started, I'd look at this module's jsonapi.routing.yml and src/Routing/Routes.php. Just look at the routes and getEntryPointRoute methods to begin with (ignore line 107).

If you get started with the most basic thing, we can build it up from there.

malik.kotob’s picture

StatusFileSize
new26.97 KB
new9.84 KB

This patch is very very very rough :) It does two things:
1) Pulls out the code from previous patches into a submodule
2) Introduces the new route for existing resources mentioned in #3.

I tested that the file uploader service was still in good shape after I finished step 1, but don't actually have the route code working. I'm posting this work-in-progress because my bandwidth is limited at the moment, so I'm leaving it here in the event that someone else can pick it up and run with it.

malik.kotob’s picture

StatusFileSize
new32.83 KB
new10.42 KB

Alrighty, here's another rough patch, but with more progress.

Highlights:
- Added the controller for file upload requests for file uploads to existing entities
- Added a service provider to register 'application/octet-stream' as a known (bin) format. Core does this in 8.6.x, but we probably can't/don't want to add in that dependency here.

Outstanding:
- Add the routes and corresponding controller code for files being uploaded to new entities.
- Add in the code to handle subrequests so that a file being uploaded to an existing entity can be taken care of in one fell swoop (I guess this doesn't have to be MVP, but it's up to you all!)
- Address any comments from a future review :D

gabesullice’s picture

Status: Needs review » Needs work

THIS IS AMAZING PROGRESS! Nice work!

  1. diff --git a/jsonapi_file_upload/jsonapi_file_upload.info.yml b/jsonapi_file_upload/jsonapi_file_upload.info.yml
    

    Let's move this to a modules directory

  2. +++ b/jsonapi_file_upload/jsonapi_file_upload.info.yml
    @@ -0,0 +1,7 @@
    +description: Provides JSON API endpoints for uploading files.
    

    I try to avoid the word "endpoint"

    "Provides JSON API file upload support"

  3. +++ b/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,133 @@
    +    /** @var \Drupal\jsonapi_file_upload\Shim\FileUploader $fileUploader */
    +    $fileUploader = $this->fileUploader;
    

    I don't think this is necessary.

  4. +++ b/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,133 @@
    +    $entity_type_id = $resource_type->getEntityTypeId();
    ...
    +    $entity = $request->get($entity_type_id);
    

    Nit: Let's just do this in one line.

  5. +++ b/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,133 @@
    +      $reason = $access_result->getReason() ?: 'Access Denied';
    

    You'll need to check $access_result instanceof AccessResultReasonInterface.

  6. +++ b/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,133 @@
    +    fclose($stream);
    

    The Go language has a defer keyword which I always miss for this kind of thing.

  7. +++ b/jsonapi_file_upload/src/JsonapiFileUploadServiceProvider.php
    @@ -0,0 +1,23 @@
    +/**
    + * Adds 'application/octet-stream' as a known (bin) format.
    + */
    +class JsonapiFileUploadServiceProvider implements ServiceModifierInterface {
    

    😘👌

    Itty bitty nit: add @internal

  8. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +    assert(is_string($jsonapi_base_path));
    +    assert($jsonapi_base_path[0] === '/');
    +    assert(substr($jsonapi_base_path, -1) !== '/');
    

    Let's not worry about these assertions, we're already doing it in the main module.

  9. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +  public function routes() {
    

    This method looks perfect 👍

  10. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +   * @param string $path_prefix
    +   *   The root path prefix.
    

    Nit: root vs base vs prefix

  11. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      $individual_file_upload_route = new Route("/{$path}/{{$entity_type_id}}/relationships/{file_field_name}");
    

    As per above, this should be defined to be /{$base_path}/ {{$entity_type_id}}/{filed_field_name}

    (so, missing relationships)

  12. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      $individual_file_upload_route->setRequirement('_csrf_request_header_token', 'TRUE');
    

    As of #2953346: Define related/relationship routes per field, not dynamically (with route parameters that need validating), JSON API has its own custom relationship field access check that we can use :)

    So we can add:

    $individual_file_upload_route->setRequirement(RelationshipFieldAccess::ROUTE_REQUIREMENT_KEY, $relationship_field_name);

  13. +++ b/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      // Resource routes all have the same controller.
    +      $routes->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handle']);
    +      $routes->addPrefix($path_prefix);
    

    Let's move these to routes()

malik.kotob’s picture

Status: Needs work » Needs review
StatusFileSize
new8.22 KB
new32.96 KB

Okay, I've addressed the above feedback and completed the outstanding items mentioned in #29, with the exception of the subrequests feature. Gabe and I discussed and feel that one is not necessarily MVP and can be postponed to a future ticket.

This patch now provides 2 routes, one that accepts file upload requests given an existing entity to upload to, and one that accepts requests without the existing entity (with the understanding that a new entity will be created). The route for existing entities is defined at: /jsonapi/entity_type/bundle/{uuid}/{file_field_name}. The route for new entities is defined at: /jsonapi/entity_type/bundle/{file_field_name}.

malik.kotob’s picture

StatusFileSize
new32.96 KB
new1.21 KB

Sorry for the noise, but now that this issue doesn't address the subrequest portion, that means we're no longer technically updating an entity with a file. We're now only creating new file entities. As such, I've attached a new patch that updates the method on the route that accepts an existing entity to POST from PATCH.

gabesullice’s picture

StatusFileSize
new33.15 KB

Just moving the module to a modules directory. No other changes.

gabesullice’s picture

Assigned: Unassigned » gabesullice
Status: Needs review » Active
StatusFileSize
new21.84 KB
new41.78 KB

@malik.kotob realized and discussed the fact that we need to do entity validation all over the place with me and suggested moving that out to a trait. Done.

Assigning to myself for a few more changes to come.

EDIT: ignore this interdiff, it has uncommitted changes in it.

gabesullice’s picture

StatusFileSize
new11.54 KB

Proper interdiff for #34.

gabesullice’s picture

StatusFileSize
new3.49 KB
new41.65 KB

Just code standards and cleanup.

gabesullice’s picture

Assigned: gabesullice » Unassigned
StatusFileSize
new8.64 KB
new44.25 KB

Here's my though process and explanation for the attached changes.

P.S. I realize my above interdiffs result in broken code. They're mostly split up in a way that's easy to see what was being changed. This patch should make it all workable again.


I think next up is a set of good tests.


  1. +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,139 @@
    +  public function handle(Request $request, ResourceType $resource_type) {
    
    +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +    $routes->addDefaults([RouteObjectInterface::CONTROLLER_NAME => 'jsonapi_file_upload.request_handler:handle']);
    

    Instead of using the same controller method for both new and existing resources, let's make dedicated ones.

  2. +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,139 @@
    +    $access_result = FileUploader::checkFileUploadAccess($this->currentUser, $field_definition, $entity);
    

    Let's do the access checking before anything else.

  3. +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -0,0 +1,139 @@
    +      $reason = 'Access Denied';
    

    Let's make this message a little more helpful to the user.

  4. +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      $individual_file_upload_route = new Route("/{$path}/{file_field_name}");
    ...
    +      $individual_file_upload_existing_entity_route = new Route("/{$path}/{{$entity_type_id}}/{file_field_name}");
    

    Let's rename these to new_resource_file_upload_route and existing_resource_file_upload_route.

  5. +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      $routes->add(static::getFileUploadRouteName($resource_type, 'individual.post'), $individual_file_upload_route);
    ...
    +      $routes->add(static::getFileUploadRouteName($resource_type, 'individual.post'), $individual_file_upload_existing_entity_route);
    

    Let's also rename these using that "new" and "existing" language.

  6. +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,175 @@
    +      $routes->addOptions(['parameters' => [$entity_type_id => ['type' => 'entity:' . $entity_type_id]]]);
    

    A cool trick we can do here, since this is new development, is name the parameter "entity" and not vary it per entity type.

    That will let us add it as a method argument in the controller.

gabesullice’s picture

StatusFileSize
new44.75 KB

Rebased.

gabesullice’s picture

Status: Active » Needs review
StatusFileSize
new5.49 KB
new45.71 KB

Adding subrequests and fixed an entity validation issue. Also, a variable name typo.

gabesullice’s picture

StatusFileSize
new1.29 KB
new45.65 KB

1 new CS violation and a leftover line.

malik.kotob’s picture

Awesome job, @gabesullice!! I tested the patch from #40 on user entities with success. I haven't had a chance to code review yet, but as you mentioned in chat earlier, this could really use a review from @Wim Leers and/or @e0ipso.

wim leers’s picture

@malik.kotob+++++++++++++++++++++++ for getting this going again! 👏 👌

@gabesullice++ for providing timely feedback, which I obviously gloriously failed at.

I wonder if we should add a namespace like Drupal\jsonapi\Shim.

Sounds good — #2929932: Work around core's ill-designed @FieldType-level TimestampItemNormalizer normalization until #2926508 lands did something similar: it added Drupal\jsonapi\ForwardCompatibility. Perhaps that should've been named Shim instead?

The more I think about this, the more I think it belongs in a submodule, not the main module.

Could you share your thought process?

Is there anything else?

Not that I can think of off the top of my head.

I can 😇 😁 … Test coverage! It was super super hard to get #1927648: Allow creation of file entities from binary data via REST requests right, and an essential piece was its test coverage. Without test coverage, this is extremely risky to add, because it may even result in one being able to overwrite files, upload new executable PHP code, and so on.

I'll review the patch, but without considering every tiny little detail that went into #1927648: Allow creation of file entities from binary data via REST requests. In fact, in this first review, I'll stay mostly at the architectural level.

  1. --- /dev/null
    +++ b/modules/jsonapi_file_upload/jsonapi_file_upload.info.yml
    

    (Repeating myself, sorry.)

    Why a separate module?

  2. +++ b/modules/jsonapi_file_upload/src/JsonapiFileUploadServiceProvider.php
    @@ -0,0 +1,25 @@
    +/**
    + * Adds 'application/octet-stream' as a known (bin) format.
    + *
    + * @internal
    + */
    +class JsonapiFileUploadServiceProvider implements ServiceModifierInterface {
    +
    +  /**
    +   * {@inheritdoc}
    +   */
    +  public function alter(ContainerBuilder $container) {
    +    if ($container->has('http_middleware.negotiation') && is_a($container->getDefinition('http_middleware.negotiation')->getClass(), NegotiationMiddleware::class, TRUE)) {
    +      $container->getDefinition('http_middleware.negotiation')->addMethodCall('registerFormat', ['bin', ['application/octet-stream']]);
    +    }
    +  }
    +
    +}
    

    This is ForwardCompatibility again, because in 8.6 \Drupal\file\FileServiceProvider will ship which makes this obsolete.

    Hence we also want a @todo indicating this should be removed once JSON API requires >=8.6.

  3. +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,177 @@
    +    // Only accept 'Content-Type: application/octet-stream' requests.
    +    $routes->addRequirements(['_content_type_format' => 'bin']);
    

    👍

  4. +++ b/modules/jsonapi_file_upload/src/Routing/Routes.php
    @@ -0,0 +1,177 @@
    +      $routes->add(static::getFileUploadRouteName($resource_type, 'new_resource'), $new_resource_file_upload_route);
    ...
    +      $routes->add(static::getFileUploadRouteName($resource_type, 'existing_resource'), $existing_resource_file_upload_route);
    

    How are these URLs discoverable? AFAICS file upload routes are never linked from anywhere? (I grepped for .file_upload., 0 results. Perhaps that's not the right way to search?)

  5. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
    @@ -0,0 +1,450 @@
    +class FileUploader implements FileUploaderInterface {
    

    This is basically a first attempt at doing #2983404: [PP-1] RESTs FileUploadResource plugin is only able to check `create` access to a parent entity, should be able to check `edit` also. In essence, it lifts a reusable component out of an existing implementation.

    It'd be super valuable to validate this lifting by posting a patch to #2983404: [PP-1] RESTs FileUploadResource plugin is only able to check `create` access to a parent entity, should be able to check `edit` also that adds this service to Drupal core, refactors \Drupal\file\Plugin\rest\resource\FileUploadResource to instead call this service (or perhaps it should be a trait?), and try to make it pass tests.

    If it still passes tests, then that would give us a lot of confidence, and would make it much easier to commit this patch :)

  6. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php
    @@ -0,0 +1,45 @@
    +interface FileUploaderInterface {
    

    Why have an interface?

  7. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploaderInterface.php
    @@ -0,0 +1,45 @@
    +  public function handleFileUploadForField(FieldDefinitionInterface $field_definition, $filename, $stream, AccountInterface $owner);
    

    This signature looks great!

  8. +++ b/src/Controller/EntityResource.php
    @@ -209,6 +166,8 @@ class EntityResource {
    +   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
    +   *   Thrown when the entity does not pass validation.
    
    @@ -283,6 +242,8 @@ class EntityResource {
    +   * @throws \Drupal\jsonapi\Exception\UnprocessableHttpEntityException
    +   *   Thrown when the patched entity does not pass validation.
    

    These can easily be moved to a separate issue. (It's out of scope, and would make this patch a bit smaller and thus easier to review and commit.)

  9. +++ b/src/Entity/EntityValidationTrait.php
    @@ -0,0 +1,61 @@
    + * @package Drupal\jsonapi\Entity
    

    Nit: remove this, and @internal instead :)

  10. +++ b/src/Entity/EntityValidationTrait.php
    @@ -0,0 +1,61 @@
    +trait EntityValidationTrait {
    

    We could do this as part of that same separate issue.

P.S.: I just posted https://github.com/json-api/json-api/issues/246#issuecomment-406747393 — pointing the JSON API spec contributors to Drupal core's solution. Hopefully it'll contribute to getting to consensus there!

btully’s picture

Hey guys! Great progress on this. Just wanted to wanted to make you aware of a bug we experienced earlier today when testing jsonapi_file_upload.

On line 367 of src/Shim/FileUploader.php there is the following line:

    if (!$this->systemFileConfig->get('allow_insecure_uploads') && preg_match(FILE_INSECURE_EXTENSION_REGEX, $filename) && (substr($filename, -4) != '.txt')) {

which relies on the FILE_INSECURE_EXTENSION_REGEX constant. Unfortunately that constant is undefined in core < 8.6, and results in the following error:

Warning: preg_match(): Delimiter must not be alphanumeric or backslash in Drupal\jsonapi_file_upload\Shim\FileUploader->prepareFilename() (line 367 of /mnt/www/html/---/docroot/modules/contrib/jsonapi/modules/jsonapi_file_upload/src/Shim/FileUploader.php) #0 /mnt/www/html/---/docroot/core/includes/bootstrap.inc(582): _drupal_error_handler_real(2, 'preg_match(): D...', '/mnt/www/html/r...', 367, Array) #1 [internal function]: _drupal_error_handler(2, 'preg_match(): D...', '/mnt/www/html/r...', 367, Array) #2 /mnt/www/html/---/docroot/modules/contrib/jsonapi/modules/jsonapi_file_upload/src/Shim/FileUploader.php(367): preg_match('FILE_INSECURE_E...', '58309e68-1e98-4...') #3 

So perhaps to be backwards compatible we need to check to see if the constant is defined, and if not define it?

gabesullice’s picture

Thanks @btully! I was not aware. Great catch!

I think by the time this makes it into JSON API (in the 2.x branch) 8.6.0 will be the minimum supported version.

That was an oversight, but this issue got delayed long enough for it to not be a problem, oddly enough :)

grimreaper’s picture

StatusFileSize
new46.92 KB

Hello,

Thanks all for the work done here.

As it is impossible to apply patch from comment 40 on 2.0.0-beta1, I have rebased the patch against the last dev version.

Finally on a project using JSON API (but not Entity share...).

gabesullice’s picture

Hello again! Thanks @Grimreaper!

grimreaper’s picture

Flow 2 OK! \o/

Can't do Flow 1 because on the media entity the file field is processed and required.

curl -X POST \
  http://XXX/fr/jsonapi/media/settlement/field_media_file \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Basic XXX' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Disposition: attachment; filename=\"XXX.pdf\"' \
  -H 'Content-Type: application/octet-stream' \
  -H 'Postman-Token: 362968b8-1528-4580-b3a8-76d75d0c2263'

Then

curl -X POST \
  http://XXX/fr/jsonapi/media/settlement \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Basic XXX' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/vnd.api+json' \
  -H 'Postman-Token: dda55410-d2ff-4418-a398-501fc851a642' \
  -d '{
  "data": {
    "type": "media--settlement",
    "attributes": {
        "status": true,
        "name": "Règlement 1 (JSON API)"
    },
    "relationships": {
		"field_media_file": {
		    "data": {
		        "type": "file--file",
		        "id": "cd9815bb-119c-4046-a2a4-26277fd511c0",
		        "meta": {
		            "display": null,
		            "description": null
		        }
		    }
		}
          }
	}
  }'

Maybe see you guys at Drupal Europe next week!

gabesullice’s picture

That's awesome.

Unfortunately, I don't think anyone from the team is gonna be there :(

grimreaper’s picture

Is the PATCH/UPDATE of file entities supported?

PS: I have seen the work on the documentation pages. Great!

gabesullice’s picture

Is the PATCH/UPDATE of file entities supported?

I'm not sure what you mean by that. Can you describe it in more detail?

grimreaper’s picture

Using the BO I think it is impossible.

But programmatically on the project I was for months, we had to do custom imports (absolutely not related to JSON API or webservices).

Depending on a file hierarchy with naming rules on a path on the server, if the client (or customer, not a client as in client/server :)) updated a file, if there was an already existing file entity in Drupal, we updated this file entity and we did not crreate a new one.

So I am asking, is it possible to update an existing file entity using JSON API? Or should a new file entity be created each time?

pfrilling’s picture

StatusFileSize
new45.16 KB
new615 bytes

I was testing this patch uploading image media and I came across this error message:

ArgumentCountError: Too few arguments to function Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel::__construct(), 1 passed in /var/www/html/web/modules/contrib/jsonapi/modules/jsonapi_file_upload/src/Controller/RequestHandler.php on line 145 and at least 2 expected in Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel->__construct() (line 51 of /var/www/html/web/modules/contrib/jsonapi/src/JsonApiResource/JsonApiDocumentTopLevel.php) 

#0 /var/www/html/web/modules/contrib/jsonapi/modules/jsonapi_file_upload/src/Controller/RequestHandler.php(145): Drupal\jsonapi\JsonApiResource\JsonApiDocumentTopLevel->__construct(Object(Drupal\file\Entity\File)) 

#1 [internal function]: Drupal\jsonapi_file_upload\Controller\RequestHandler->handleFileUploadForNewResource(Object(Symfony\Component\HttpFoundation\Request), Object(Drupal\jsonapi\ResourceType\ResourceType), 'field_media_ima...') .....

The curl command I was using:

curl -X POST \
  http://XXXX/jsonapi/media/image/field_media_image \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Basic XXX==' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Disposition: attachment; filename=\"test1.png\"' \
  -H 'Content-Type: application/octet-stream' \
  -H 'Postman-Token: 17dc46b4-3603-405b-9d4f-8dfa43ed83b1'

The attached patch fixed the issue for me. Please review and let me know if something looks incorrect.

gabesullice’s picture

Status: Needs review » Needs work

@pfrilling, thanks! You're correct that it needs an array of links. However, it will need a self link. See EntityResource::buildWrappedResponse() for an example :)

pfrilling’s picture

StatusFileSize
new45.26 KB
new1.03 KB

Thanks for the feedback @gabesullice! Attached is a new patch and interdiff. I think I did this correctly. Let me know if this looks okay.

pfrilling’s picture

Status: Needs work » Needs review

I forgot to mark this as needs review earlier.

The last submitted patch, 37: interdiff-36-37.patch, failed testing. View results

The last submitted patch, 36: 2958554-36.patch, failed testing. View results

The last submitted patch, 34: 2958554-34.patch, failed testing. View results

gabesullice’s picture

Status: Needs review » Needs work
+++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
@@ -142,7 +143,9 @@
+    $links['self'] = LinkManager::getRequestLink($request);

getRequestLink is not a static method. So the link manager service will need to be injected and called with an arrow ->.

pfrilling’s picture

Status: Needs work » Needs review
StatusFileSize
new2.33 KB
new45.59 KB

Thanks again for the feedback.

Attached is a new patch. Hopefully I have it correct now.

gabesullice’s picture

👌 looks great @pfrilling!

Would you like to try your hand at #42.5?

wim leers’s picture

Assigned: Unassigned » wim leers
Status: Needs review » Needs work

#43: Thanks for testing!
#44: I partially agree; I think that this particular feature should work only on Drupal 8.6 and newer, but I don't think we need to make all of JSON API 2.x 8.6-only. It'd complicate the upgrade path for sites; it's better if also sites on 8.5 can update to JSON API 2.x, because that means they are not required to update Drupal core to 8.6 to be able to use it, hence simplifying future updates!
#52 + #60: Thanks, @pfrilling! 🎉

Because this issue is blocking #2987608 as of #2987608-32: Move deserialization from RequestHandler to JsonApiParamEnhancer, I'm assigning this to me for a next round of work.

grimreaper’s picture

Hello,

I now have another use case. Due to the document size, files will be placed in the public/private stream wrapper folder by a process (not important). And I need to create the corresponding file entities.

I tried the following requests without success:

curl -X POST \
  http://XXX/fr/jsonapi/file/file \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Basic XXX' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/vnd.api+json' \
  -H 'Postman-Token: a5194d0c-34bd-4083-ad4f-aab68653f51e' \
  -d '{
  "data": {
    "type": "file--file",
    "id": "MY_ID",
    "attributes": {
        "filename": "test.txt",
        "status": true
    }
  }
}'

Returns a 403 forbidden, I think due to the permissions on file entity as there is no explicit "create" permission on the file entity.

And

curl -X POST \
  http://XXX/fr/jsonapi/media/settlement/field_media_file \
  -H 'Accept: application/vnd.api+json' \
  -H 'Authorization: Basic XXX' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/vnd.api+json' \
  -H 'Postman-Token: 49c6a648-d38a-4ba8-a9b6-32dcf5549cae' \
  -d '{
  "data": {
    "type": "file--file",
    "id": "MY_ID",
    "attributes": {
        "filename": "test.pdf",
        "uri": {
            "value": "public://test.pdf",
            "url": "/sites/default/files/test.pdf"
        },
        "status": true
    }
  }
}'

Returns a 415 Unsupported media type.

Same result if I try on jsonapi/media/settlement/UUID/field_media_file endpoint.

So, it is no more possible to create a file entity without posting data?

gabesullice’s picture

@Grimreaper, does your first example request work without this patch?

grimreaper’s picture

@gabesullice,

Without the patch:

  • the first request is still a 403
  • the second request is now a 405 method not allowed

So it seems it was not possible even before the patch.

wim leers’s picture

  1. You can't post to /jsonapi/file/file — you cannot create file entities directly, you need to upload them in the context of a specific file field. See IS and see https://www.drupal.org/node/2941420.
  2. It looks like you're doing the second step (second request) in flow #2, but you're POSTing not to a specific resource and you're still trying to create a File entity (file--file resource).

It's not clear to me what you're trying to do. I think something else is already uploading the file, and now you're just trying to create a File entity. Is that correct?

grimreaper’s picture

Hello,

@Wim Leers thanks for the reply.

I am trying to create a file entity without file uplaod because the physical file has been already placed in the file system.

But if it can't be done, it is ok, I will find a solution for the project.

Another thing I realized, with either flows, it seems that it is impossible to create a file entity with a predefined UUID. Or I am missing something?

I think for Entity share, I will have problems with that, because currently on JSON API 1.x, using deserialization I can create file entities with predefined UUID, but with JSON API 2.x and the need to make subrequest, I don't know if it will still be possible. I will see when I will be confronted to the problem.

wim leers’s picture

I am trying to create a file entity without file uplaod because the physical file has been already placed in the file system.

This is definitely an edge case. How does the file get there then? There must be some other code making this happen, right? Can't that code then also create a File entity for you?

This is also not going possible in core REST's file upload support (see https://www.drupal.org/node/2941420). We'd need to create a new "move file already on server" endpoint for both core REST and JSON API to make this work.

grimreaper’s picture

The file is placed using a FTP connexion, there is no Drupal code involved in this process.

So yes, I think I will need to implement a custom endpoint to create those file entities.

Thanks for your replies.

gabesullice’s picture

@Grimreaper, check out https://www.drupal.org/project/jsonrpc for that custom solution.

grimreaper’s picture

Thanks @gabesullice for the suggestion.

I will give it a look. I do not garantee to use it, but at least I will think more about it in the future.

grimreaper’s picture

StatusFileSize
new47.48 KB

Hello,

Rebasing patch from comment 60 to be able to apply it on version 8.x-2.0-beta2.

Edit: the only conflict was in the imports of jsonapi/src/Controller/EntityResource.php

wim leers’s picture

StatusFileSize
new1.33 KB
new46.53 KB
wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new26.1 KB
new1.31 KB
new32.33 KB
new72.9 KB

There are two key things that still need to happen for this patch:

  1. test coverage
  2. prove that the shim works in core's rest.module too

Tackling those.

This does the first: test coverage (interdiff-tests.txt). It unveiled a tiny bug in the existing patch (interdiff-logic_fix.txt). It's based on core's \Drupal\Tests\rest\Functional\FileUploadResourceTestBase, which was basically copy/pasted into \Drupal\Tests\jsonapi_file_upload\Functional\FileUploadTest and modified for JSON API. It's still 99% identical (see diff_rest_vs_jsonapi_test.txt).

This should be green! 🎉

wim leers’s picture

Issue tags: +Needs tests

Note that #74 is only testing "flow #2" as the issue summary describes it: the request flow that requires two requests. This is also the flow that works in all cases. Flow #1 requires only a single request but only works in some cases. This flow still needs test coverage. But the test coverage in #74 already tests most things :)

Status: Needs review » Needs work

The last submitted patch, 74: 2958554-74.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new72.93 KB
new636 bytes
Missing @group annotation in Drupal\Tests\jsonapi_file_upload\Functional\FileUploadTest in /var/www/html/core/modules/simpletest/src/TestDiscovery.php:360
Stack trace:
…

🙃

gabesullice’s picture

  1. +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -154,9 +154,11 @@ class RequestHandler {
    -    $links['self']['href'] = $this->linkManager->getRequestLink($request);
    +    // @todo Remove line below in favor of commented line in https://www.drupal.org/project/jsonapi/issues/2878463.
    +    $links['self']['href'] = Url::fromRoute('jsonapi.file--file.individual', ['entity' => $file->uuid()])->setAbsolute(TRUE)->toString(TRUE)->getGeneratedUrl();
    

    I'm not sure I agree that this link should be the link for the individual file entity. Remember, the resource object under the data key will have its own self link that is the same as this one.

    This self link is on the document level and should be the URL of the requested resource.

  2. +++ b/modules/jsonapi_file_upload/src/Controller/RequestHandler.php
    @@ -154,9 +154,11 @@ class RequestHandler {
    -    return new ResourceResponse(new JsonApiDocumentTopLevel($file, new NullEntityCollection(), $links), '200', []);
    +    return new ResourceResponse(new JsonApiDocumentTopLevel($file, new NullEntityCollection(), $links), 201, []);
    

    200 -> 201 | Good catch!

wim leers’s picture

StatusFileSize
new541 bytes
new72.96 KB

#77 passed on 8.7 and 8.6. On 8.5, this happened:

Notice: Use of undefined constant FILE_INSECURE_EXTENSION_REGEX - assumed 'FILE_INSECURE_EXTENSION_REGEX

So, we should require Drupal >=8.6. New features for 8.5 make little sense anyway; 8.5 is on security-only support.

wim leers’s picture

Running #79 + #2987608-28: Move deserialization from RequestHandler to JsonApiParamEnhancer + #2987610-28: Remove RequestHandler class and service and add EntityResource methods to each route definition to answer my concern in #2987608-32: Move deserialization from RequestHandler to JsonApiParamEnhancer, where I wrote:

My chief concern is that this may get in the way of #2958554: Allow creation of file entities from binary data via JSON API requests, which is IMHO far more important than this because it brings an important new capability to JSON API. In core REST, this required changes to RequestHandler. But #2958554 is doing stuff with an alternative RequestHandler, just like core REST. So I think that before this can be committed, we need to land #2958554: Allow creation of file entities from binary data via JSON API requests (ideal case) or we need to be very certain that removing JSON API's RequestHandler won't get in the way. Hence keeping at Needs review.

If this patch passes tests, then I am comfortable with marking #2987608: Move deserialization from RequestHandler to JsonApiParamEnhancer RTBC and committing it. Locally, it does pass tests:

#!/usr/bin/env php
PHPUnit 6.5.11 by Sebastian Bergmann and contributors.

Testing Drupal\Tests\jsonapi_file_upload\Functional\FileUploadTest
SSSSSS...........                                                 17 / 17 (100%)

Time: 2.19 minutes, Memory: 6.00MB

OK, but incomplete, skipped, or risky tests!
Tests: 17, Assertions: 170, Skipped: 6.

Process finished with exit code 0

So I'm expecting the same to happen here :)

wim leers’s picture

🎉

wim leers’s picture

Issue summary: View changes

Updated IS with remaining tasks.

wim leers’s picture

Assigned: wim leers » e0ipso

Having been through this patch and this issue again today, I now also agree with the need for this to be a separate module for as long as the JSON API spec does not support file uploads.

@e0ipso, do you also agree?

wim leers’s picture

e0ipso’s picture

Assigned: e0ipso » Unassigned

@e0ipso, do you also agree?

I don't have a strong preference. I can see benefits one way or the other. My vote goes to whatever the majority think.

gabesullice’s picture

I do believe it should be a separate module which needs to be enabled. My preference is a to have it as a submodule as it already is in the patch. However, if @Wim Leers feels strongly that it should be in contrib, I'm fine w/ that too.

wim leers’s picture

  1. If jsonapi were to remain a contrib module, +1 for a submodule.
  2. If jsonapi goes into core, it'll have to become a separate contrib module anyway. Core doesn't have submodules.

Unless we're saying we want to add both modules to core. I can see that argument too.

gabesullice’s picture

Unless we're saying we want to add both modules to core.

Can core modules have submodules?

wim leers’s picture

Not AFAIK. Hence why I wrote Core doesn't have submodules.

gabesullice’s picture

🙈

Okay... hm.

I would not like JSON API to be in core and have file uploads only supported by a contrib module.

That means our only choice would be between trying to get two modules into core, or to merge this into JSON API itself.

So... maybe we should make it part of the main module. Previously, I said:

[File upload support] would be yet another extension to the spec that we'd be putting in the module without a negotiation or advertisement mechanism. That's why I said it should be separate. I would be willing to be convinced otherwise.

That means I'm now arguing against myself. What's changed?

In some respects, this actually lives "outside" the spec, because the HTTP request Content-Type header is not the JSON API media type. The HTTP response part of the upload flow is within the spec though, but the response is actually spec-compliant.

Given those facts, I'm not too worried that we'll end up in conflict with the spec... only that we may end up competing with the spec if the spec maintainers come up with an alternative pattern (I think this is very unlikely). In that case, we'll have to implement both which is a poor DX.

Last point: the next version of the spec actually does have a negotiation method now (it didn't when I wrote that comment). That means that unlike before, we'd have a way to support two different file upload strategies if we wanted to.

wim leers’s picture

Last point: the next version of the spec actually does have a negotiation method now (it didn't when I wrote that comment). That means that unlike before, we'd have a way to support two different file upload strategies if we wanted to.

This is a very good point.

In that sense, this would be "just another" extension/profile that the Drupal implementation provides by default, just like we already have for filters, pagination and relationship arity.

wim leers’s picture

Assigned: Unassigned » wim leers
StatusFileSize
new4.13 KB
new73.57 KB

In preparation for #2940383: [META] Unify file upload logic of REST and JSON:API, let's reduce the JSON API-coupledness of the shim.

wim leers’s picture

Rationale for #95:

  1. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
    @@ -15,13 +17,13 @@ use Drupal\Core\Session\AccountInterface;
    -use Drupal\jsonapi\Entity\EntityValidationTrait;
    
    @@ -38,10 +40,6 @@ use Symfony\Component\HttpKernel\Exception\HttpException;
    -  use EntityValidationTrait {
    -    validate as resourceValidate;
    -  }
    
    @@ -321,23 +326,33 @@ class FileUploader implements FileUploaderInterface {
    -    static::resourceValidate($file);
    

    This was the only dependency on JSON API-specific logic.

  2. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
    @@ -321,23 +326,33 @@ class FileUploader implements FileUploaderInterface {
    +    $violations = $file->validate();
    +
    +    // Remove violations of inaccessible fields as they cannot stem from our
    +    // changes.
    +    $violations->filterByFieldAccess();
    

    This is what I replaced it with. We needed only a subset of the logic in that trait method!

  3. +++ b/modules/jsonapi_file_upload/src/Shim/FileUploader.php
    @@ -321,23 +326,33 @@ class FileUploader implements FileUploaderInterface {
    -      throw new UnprocessableEntityHttpException($message);
    ...
    +        $violations->add($violation);
    ...
    +    return $violations;
    

    Furthermore, rather than throwing an exception, I'm now instead mapping file_validate()-returned errors into constraint violations, so that we can just return constraint violations, and let each respective HTTP API (JSON API, REST, GraphQL) decide how to convert those violations into errors/responses.

wim leers’s picture

Assigned: wim leers » Unassigned
StatusFileSize
new6.36 KB
new74.7 KB

Still green ✅

This then uses the initial step that #96.3 made, and takes it all the way.

wim leers’s picture

Issue summary: View changes

Great, still passing tests! This closely matches the core service proposed in #2940383-7: [META] Unify file upload logic of REST and JSON:API.

The only differences are:

index cf344af..22a5ab6 100644
--- a/core/modules/file/src/FileUploader.php
+++ b/core/modules/file/src/FileUploader.php
@@ -1,6 +1,6 @@
 <?php
 
-namespace Drupal\file;
+namespace Drupal\jsonapi_file_upload\Shim;
 
 use Drupal\Component\Utility\Bytes;
 use Drupal\Component\Utility\Crypt;
@@ -8,6 +8,7 @@
 use Drupal\Core\Entity\Plugin\DataType\EntityAdapter;
 use Drupal\Core\Field\FieldDefinitionInterface;
 use Drupal\Core\Validation\DrupalTranslator;
+use Drupal\file\FileInterface;
 use Drupal\Core\File\FileSystemInterface;
 use Drupal\Core\Config\ConfigFactoryInterface;
 use Drupal\Core\Lock\LockBackendInterface;
@@ -33,6 +34,8 @@
  *     to be later moved when they are referenced from a file field.
  *   - Permission to upload a file can be determined by a user's field and
  *     entity level access.
+ *
+ * @internal
  */
 class FileUploader implements FileUploaderInterface {

Which means that

  1. Prove that the shim works in core's rest.module too

is DONE!

That only leaves:

  1. Test coverage for "Flow #1"
wim leers’s picture

Assigned: Unassigned » wim leers

Working on that last test coverage BTW :)

wim leers’s picture

Issue tags: -Needs tests

This adds test coverage for flow #1. It should fail, due to 2 small bugs in \Drupal\jsonapi_file_upload\Controller\RequestHandler.

wim leers’s picture

StatusFileSize
new7.51 KB
new79.01 KB

This adds test coverage for flow #1. It should fail, due to 2 small bugs in \Drupal\jsonapi_file_upload\Controller\RequestHandler.

wim leers’s picture

Assigned: wim leers » Unassigned
Issue summary: View changes
Issue tags: +Needs documentation, +Needs change record
StatusFileSize
new1.2 KB
new79.08 KB

The two small bugs:

  1. s/entity type ID/'entity'/ for route parameter :)
  2. the generated URL for the subrequest was not passing the $server parameter, which meant that the hostname was unknown, which resulted in http://localhost rather than the correct (current request's) hostname.

This should be green.

Now all that remains:

  1. documentation
  2. change record to announce this — perhaps just linking to the documentation
  3. final review
  4. … and 2.0 needs to be released first, this should be in version 2.1 :)
wim leers’s picture

  1. final review

Oh, and that review should also settle/decide whether we want this to be a submodule, or whether we think this should be merged with the jsonapi module itself. I think that in #90 + #91 we agreed to move this into jsonapi itself?

(Moving that is trivial, what matters most is complete test coverage, which we now have!)

The last submitted patch, 101: 2958554-100.patch, failed testing. View results

gabesullice’s picture

I think that in #90 + #91 we agreed to move this into jsonapi itself?

Yes, this should be moved into the main module.

wim leers’s picture

StatusFileSize
new22 KB
new76.28 KB

Status: Needs review » Needs work

The last submitted patch, 106: 2958554-106.patch, failed testing. View results
- codesniffer_fixes.patch Interdiff of automated coding standards fixes only.

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new627 bytes
new76.22 KB

Status: Needs review » Needs work

The last submitted patch, 108: 2958554-108.patch, failed testing. View results

wim leers’s picture

Assigned: Unassigned » gabesullice

Hah. That one failure is because the jsonapi.node--cat.file_upload.existing_resource route still is a route that on a fundamental level matches requests made to /jsonapi/node/cat/UUID/field_test, but it only supports POST and hence results in a 405 response. Previously it resulted in a 404 response.

+++ b/src/Routing/Routes.php
@@ -188,6 +194,62 @@ class Routes implements ContainerInjectionInterface {
+  protected static function getFileUploadRoutesForResourceType(ResourceType $resource_type, $path_prefix) {
+    $routes = new RouteCollection();
+
+    // @todo Remove this when JSON API requires Drupal >=8.6, see https://www.drupal.org/node/1927648.
+    if (!defined('FILE_INSECURE_EXTENSION_REGEX')) {
+      return $routes;
+    }
+
+    // Internal resources have no routes; individual routes require locations.
+    if ($resource_type->isInternal() || !$resource_type->isLocatable()) {
+      return $routes;
+    }
+
+    if ($resource_type->isMutable()) {
+      $path = $resource_type->getPath();
+      $entity_type_id = $resource_type->getEntityTypeId();
+
+      $new_resource_file_upload_route = new Route("/{$path}/{file_field_name}");

I think that changing this logic to generate routes specific to each file field that exists would solve this. That'd also mean that we don't generate such file upload routes for entity types that don't even have file fields (which is usually most of them).

Thoughts?

gabesullice’s picture

@Wim Leers, you're absolutely right! The current code is just vestige of the past and a reminder how long this issue has been open.

Back in July, @malik.kotob and I had this conversation:

Malik Kotob [9:54 AM]
okay, that makes sense, so the difference between this new route and the existing ones is then just the trailing `/relationships/field_file`

so i guess that should really be `/relationships/{field_name}`

Gabe Sullice [9:55 AM]
That would be a good start :slightly_smiling_face: In the end, I think we'd want to inspect the resource type and make routes for each file field (but don't worry about that for now)
I _just_ did a similar thing here: https://www.drupal.org/project/jsonapi/issues/2953346
Mar 15th
so, let's let that land before worrying about it for files

The issue I linked to was: #2953346: Define related/relationship routes per field, not dynamically (with route parameters that need validating)

And it has already landed! So it's time to do that here as well :)

wim leers’s picture

Assigned: gabesullice » Unassigned

👌

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new3.23 KB
new1.68 KB
new77.43 KB

Done. Unfortunately new edge cases arise. For example, when POST /jsonapi/entity_test/entity_test/field_rest_file_test_INVALID (notice the suffix), it'll result in a 405, not in a 404. Because /jsonapi/entity_test/entity_test/{WILDCARD} no longer exists, and that's what used to validate … oh wait 🤔

That's because I specifically was generating a different route per file field. Only generating a single route if >=1 file field exists on a resource type is sufficient. Then the exact same approach as #2953346: Define related/relationship routes per field, not dynamically (with route parameters that need validating) can be used! 👌

wim leers’s picture

StatusFileSize
new4.96 KB
new77.08 KB

Rebased now that #3007274: s/JSON API/JSON:API/ landed.

Status: Needs review » Needs work

The last submitted patch, 114: 2958554-114.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new567 bytes
new77.43 KB

Made a mistake in rebasing. Also explains the difference in patch size between #113 and #114.

gabesullice’s picture

Status: Needs review » Reviewed & tested by the community
StatusFileSize
new11.53 KB
new76.73 KB
  1. +++ b/src/Controller/FileUpload.php
    @@ -0,0 +1,249 @@
    +      }, (array) $violations->getIterator()));
    

    Let's use iterator_to_array($violations) here. Technically, getIterator() isn't part of the interface.

  2. +++ b/src/ForwardCompatibility/FileUploader.php
    @@ -0,0 +1,460 @@
    +    // Validate the file entity against entity-level validation and field-level
    

    Nit: s/-//g

  3. +++ b/src/ForwardCompatibility/FileUploader.php
    @@ -0,0 +1,460 @@
    +    // Firstly, check the header exists.
    

    Ubernit: s/Firstly/First

  4. +++ b/src/ForwardCompatibility/FileUploader.php
    @@ -0,0 +1,460 @@
    +      throw new BadRequestHttpException('"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided');
    ...
    +      throw new BadRequestHttpException('No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided');
    ...
    +      throw new BadRequestHttpException('The extended "filename*" format is currently not supported in the "Content-Disposition" header');
    ...
    +          $this->logger->error('Temporary file data for "%path" could not be written', ['%path' => $temp_file_path]);
    +          throw new HttpException(500, 'Temporary file data could not be written');
    ...
    +      $this->logger->error('Temporary file "%path" could not be opened for file upload', ['%path' => $temp_file_path]);
    +      throw new HttpException(500, 'Temporary file could not be opened');
    
    +++ b/tests/src/Functional/FileUploadTest.php
    @@ -0,0 +1,800 @@
    +    $this->assertResourceErrorResponse(404, 'Field "field_rest_file_test_invalid" does not exist', $invalid_uri, $response);
    ...
    +    $this->assertResourceErrorResponse(400, '"Content-Disposition" header is required. A file name in the format "filename=FILENAME" must be provided', $uri, $response);
    ...
    +    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $uri, $response);
    ...
    +    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $uri, $response);
    ...
    +    $this->assertResourceErrorResponse(400, 'No filename found in "Content-Disposition" header. A file name in the format "filename=FILENAME" must be provided', $uri, $response);
    ...
    +    $this->assertResourceErrorResponse(400, 'The extended "filename*" format is currently not supported in the "Content-Disposition" header', $uri, $response);
    

    Nit: need trailing stops (.)

  5. +++ b/src/ForwardCompatibility/FileUploaderInterface.php
    @@ -0,0 +1,46 @@
    + * This is implemented at the field-level for the following reasons:
    

    Nit: s/-//

  6. +++ b/src/ForwardCompatibility/FileUploaderInterface.php
    @@ -0,0 +1,46 @@
    + *   - Permission to upload a file can be determined by a users field level
    

    Nit: s/users/user's/

  7. +++ b/src/ForwardCompatibility/FileUploaderInterface.php
    @@ -0,0 +1,46 @@
    +   *   The owner of the file. Defaults to the current user. Note, it is the
    

    Remove: "Defaults to the current user" since it's not an optional arg.


Only nits... and they're all fixed! RTBC!

But let's not commit this until 2.0 stable. Also, still needs a CR.

wim leers’s picture

But let's not commit this until 2.0 stable. Also, still needs a CR.

+1!


  1. --- a/src/ForwardCompatibility/FileUploader.php
    +++ b/src/ForwardCompatibility/FileUploader.php
    --- a/src/ForwardCompatibility/FileUploaderInterface.php
    +++ b/src/ForwardCompatibility/FileUploaderInterface.php
    

    Can you also apply these changes to the core patch at #2940383: [META] Unify file upload logic of REST and JSON:API?

  2. +++ b/src/ForwardCompatibility/FileUploader.php
    @@ -161,7 +161,7 @@ class FileUploader implements FileUploaderInterface {
    -    // Validate the file entity against entity-level validation and field-level
    +    // Validate the file entity against entity level validation and field level
    

    Interesting. I thought it was "The foo level" and "A foo-level something".

    I'd love to learn what's right. Can you point me to some URLs? :)

gabesullice’s picture

StatusFileSize
new1.98 KB
new2.1 KB
new76.73 KB

#118:

  1. Sure!
  2. Honestly, I was just doing it for consistency, but I think I did it wrong! Turns out, if it's a "single concept" modifying a noun, then it's hyphenated.

    So, "field level access" should be "field-level access", but "implemented at the field level" is correct, because "field" is modifying "level".

    Sources: https://www.grammarbook.com/punctuation/hyphens.asp and https://english.stackexchange.com/questions/113422/how-to-use-hyphens-ap...

    I added a "corrected correction" interdiff which shows just the hyphen changes to #116.

    TIL: @Wim Leers is better and englishing than me.

awm’s picture

I applied this patch and tried to test it but it did not work for me: Here is my post request exported as curl from postman

curl -X POST \
  http://d8.vdev/jsonapi/node/article/9cff7586-deb8-4f5f-8fe6-dc05cf839ec0/field_image \
  -H 'Content-Type: application/octet-stream' \
  -H 'Postman-Token: b63d2e10-6347-4090-a89e-8c44ab68d0e4' \
  -H 'X-CSRF-Token: K4M-4LmQYkKt0gx9fvxCGDmvT9AmhF2BZGOFq5oAi_c' \
  -H 'cache-control: no-cache'

and request body is a binary file.

The response I get is

{
    "data": null,
    "jsonapi": {
        "version": "1.0",
        "meta": {
            "links": {
                "self": {
                    "href": "http://jsonapi.org/format/1.0/"
                }
            }
        }
    },
    "links": {
        "self": {
            "href": "http://d8.vdev/jsonapi/node/article/9cff7586-deb8-4f5f-8fe6-dc05cf839ec0/field_image"
        }
    }
}

But nothing gets attached to article with uuid 9cff7586-deb8-4f5f-8fe6-dc05cf839ec0. Am I missing anything?

I tried adding the accept header but as when I do I get a message "Could not get any response"

wim leers’s picture

Issue tags: +Needs tests

First of all: thanks for testing this patch! 🎉

But nothing gets attached to article with uuid 9cff7586-deb8-4f5f-8fe6-dc05cf839ec0. Am I missing anything?

Hm … it sounds like you've found a bug either way: either something is broken, or some validation of the incoming request is insufficient. Either way, we need to expand test coverage for this issue it seems. Unless you're using another module that's causing this problem. Can you reproduce this with only Drupal core + JSON:API?

Because the testPostFileUploadAndUseInSingleRequest() test is passing and it does this:

+++ b/tests/src/Functional/FileUploadTest.php
@@ -0,0 +1,800 @@
+    // Reuploading the same file will result in the file being uploaded twice
+    // and referenced twice.
+    $response = $this->fileRequest($uri, $this->testFileData);

This is doing exactly what you're doing. I'd set a global $something = TRUE; here, and then modify the fileRequest() method to check the value of that same global, and then dump/log what exactly its request is doing. Once you have that, you can compare it to what's happening for you.

awm’s picture

Ok tested this again on vannila drupal "8.6.4-dev " + jsonapi "8.x-2.0-rc2 " with the patch applied. here is my command line curl request:
note: cookie, csrf token, and path to file must be obtained and changed before you can try this your

  curl -X POST \
  http://d8.vdev/jsonapi/node/article/307a5965-6a20-4f19-a928-2663a3e05d9a/field_image \
  -H 'Accept: application/vnd.api+json' \
  -H 'Content-Type: application/octet-stream' \
  -H 'X-CSRF-Token: lBndnnbaeScxg1nRGFCxbsUq7xFyL-klXJ3mwyEj95w' \
  --trace-ascii debug.text \
  --data-binary @/path/to/file/sample.jpg \
  --cookie "SESS31b6c7d842b10fcad1130ff4da73e525=1hkBCEQ5PzsZ3b1VbCy0GjYqpnCOALlB-Glx9J8VO9Q; path=/; domain=.d8.vdev; HttpOnly; Expires=Fri, 28 Dec 2018 20:09:37 GMT;"

The result I get in apache access log is

"POST /jsonapi/node/article/307a5965-6a20-4f19-a928-2663a3e05d9a/field_image HTTP/1.1" 400 - "-" "curl/7.54.0"

and

in the error log I get;

[Wed Dec 05 17:12:06.429200 2018] [php7:notice] [pid 1404] [client 192.168.44.1:49995] Uncaught PHP Exception InvalidArgumentException: "Class "jsonapi.file_upload" does not exist." at /var/www/d8/web/core/lib/Drupal/Core/DependencyInjection/ClassResolver.php line 24

awm’s picture

Tested this again and it looks like content-despostion header is requried otherwise I get a 400 bad request.

Here is what worked for me on vanilla drupal core + jsonapi + patch:

curl -X POST \
  http://d8.vdev/jsonapi/node/article/307a5965-6a20-4f19-a928-2663a3e05d9a/field_image \
  -H 'Accept: application/vnd.api+json' \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Disposition: attachment; filename="sample5.jpg"' \
  -H 'X-CSRF-Token: lBndnnbaeScxg1nRGFCxbsUq7xFyL-klXJ3mwyEj95w' \
  --trace-ascii debug.text \
  --data-binary @/path/sample5.jpg \
  --cookie "SESS31b6c7d842b10fcad1130ff4da73e525=1hkBCEQ5PzsZ3b1VbCy0GjYqpnCOALlB-Glx9J8VO9Q; path=/; domain=.d8.vdev; HttpOnly; Expires=Fri, 28 Dec 2018 20:09:37 GMT;"
blainelang’s picture

StatusFileSize
new111.7 KB

I am successfully uploading files from REACT app via the /file/upload endpoint and attaching it to the content node using the /jsonapi/node/[entity_type]/[entity_uuid]/relationships/[field_name] end point without this patch. I'm confused then as to what this patch is going to be doing. I thought it was adding the binary file upload capability - but this is working with out any patches for me. I've tested with different file types and sizes < 100Meg including MP4 video files.

Just did a clean test on a new install so I could be certain this was all that was needed - see attached image of the modules and versions installed.
installed modules

wim leers’s picture

@awm: Correct, the Content-Disposition header is required. The error response's body will inform you about that. The error in #122 is because you applied the patch without rebuilding the container, hence it was still missing the service. Thanks for confirming in #123 that it is working correctly for you! 👍

@blainelang

but this is working with out any patches for me.

That's because you're using core's rest module and have enabled the file upload REST resource.

This patch is bringing that same capability natively to the JSON:API module, so that you don't need to enable the REST module at all, nor the REST UI module, nor any of the fairly confusing configuration that you need to do to be able to use it at all.

blainelang’s picture

Thanks @Win for clarifying!

hedeshy’s picture

Hi everybody,

In jsonapi_file it is possible to set the file UUID along with the file data. How to do it in this approach?

I mean, maybe we can have another flow for such a case.

Flow 3 (creating a new entity with defined uuid)

Request:

POST /jsonapi/entity_type/bundle/file_field/{uuid}

Content-Type: application/octet-stream
Content-Disposition: file; filename=image.jpg
Accept: application/vnd.api+json


// binary data

gabesullice’s picture

@hedeshy, can you open a feature request like this: [PP-1] Support client-generated UUIDs on file uploads and then mark it Postponed and related to this issue? I don't think that that feature needs to be in this patch.


Regarding your proposal, I think GET parameters would be a more fitting solution than a path parameter. So /jsonapi/entity_type/bundle/file_field/{uuid} would become /jsonapi/entity_type/bundle/file_field?id={uuid}. OTOH, it would be worth researching the Content-Disposition header to see if it could support a UUID, since that is already the mechanism in place for setting the file's metadata.


EDIT: I did a (very quick) bit of research. It looks like the ABNF for Content-Disposition parameters would allow for setting the file entity's UUID, like so:

POST /jsonapi/entity_type/bundle/file_field

Content-Type: application/octet-stream
Content-Disposition: file; filename=image.jpg; resource-id={uuid}
Accept: application/vnd.api+json

// binary data
andriansyah’s picture

StatusFileSize
new34.69 KB

Hi,

Thank you for the #119 patch, but after the upgrade to the 8.x-2.0-rc4, I found this error.
Only local images are allowed.

andriansyah’s picture

StatusFileSize
new76.77 KB

after doing some diff and merge using Diffmerge.
this is the patch that I used after updating to 8.x-2.0-rc4.

andriansyah’s picture

Status: Reviewed & tested by the community » Needs review

The last submitted patch, 72: jsonapi-file_creation-2958554-72.patch, failed testing. View results

The last submitted patch, 73: 2958554-73.patch, failed testing. View results

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Thanks, @andriansyahnc! That looks perfect :) (I rebased it myself, fixed the trivial conflict at the top of EntityResource, and ended up with the same diff, only with different contexts.)

wim leers’s picture

Issue tags: -Needs tests

Removing Needs tests again — I added it in #121 based on #120, but based on the response in #122, that wasn't an actual bug/problem.

wim leers’s picture

Issue tags: -Needs change record

https://www.drupal.org/project/jsonapi/releases/8.x-2.0 is out. We now actually can commit this! Change record created: https://www.drupal.org/node/3024331

Documentation can only be added after this is committed. The page already exists: https://www.drupal.org/docs/8/modules/jsonapi/file-uploads. I think adding the same content as the change record upon commit is a good start?

gabesullice’s picture

Status: Reviewed & tested by the community » Needs work

This patch fails on 8.5 (results) and 8.7 (results)

gabesullice’s picture

Status: Needs work » Needs review
StatusFileSize
new2.58 KB
new77 KB

This should fix this for 8.7 and 8.6 (I haven't tested it on 8.5, perhaps it will help there too). Also, I added a missing piece of a docblock and Accept: application/vnd.api+json to FileUploadTest::fileRequest().

gabesullice’s picture

StatusFileSize
new7.4 KB
new79.97 KB

In #79, we decided not to support file uploads on Drupal core < 8.6. Typically, we support 8.5 and above on the 2.x branch. However, this is a completely new feature, so we're not dropping support for 8.5.

We didn't previously have a problem with this because we had all this in a submodule which required >8.6 in its dependencies.

I wrote a bunch of version checks and skipped tests if < 8.6 is detected. I also changed the version detection mechanism in Routes.php from this:

    if (!defined('FILE_INSECURE_EXTENSION_REGEX')) {
      return $routes;
    }

to this:

    if (\Drupal::VERSION < 8.6) {
      return new RouteCollection();
    }

That's what we do everywhere else. What this check does is prevent any file upload routes from being generated on 8.5 and below.

IMHO, if @Wim Leers or @e0ipso agrees, this can be committed.

wim leers’s picture

Assigned: Unassigned » wim leers
StatusFileSize
new79.42 KB

#138: ugh, yeah, that's caused by upstream Symfony weirdness. We've had to make similar changes all over core. Except … #2940383: [META] Unify file upload logic of REST and JSON:API does pass on Drupal 8.7. And this patch includes a forward ported bit from there … so I don't like deviating from that class unless we have very good and documented reasons. Investigating…

In the mean time, here's a rebase, because this no longer applied since #2992833: Add a version negotiation to revisionable resource types got committed.

wim leers’s picture

StatusFileSize
new77.22 KB
new7 KB
+++ b/tests/src/Functional/FileUploadTest.php
@@ -199,6 +199,12 @@ class FileUploadTest extends ResourceTestBase {
+    // @todo Remove this when JSON:API requires Drupal >=8.6, see https://www.drupal.org/node/1927648.
+    if (floatval(\Drupal::VERSION) < 8.6) {
+      $this->markTestSkipped('JSON:API does not support file uploads below Drupal core version 8.6.');
+      return;
+    }

@@ -256,6 +262,12 @@ class FileUploadTest extends ResourceTestBase {
+    // @todo Remove this when JSON:API requires Drupal >=8.6, see https://www.drupal.org/node/1927648.
+    if (floatval(\Drupal::VERSION) < 8.6) {

Instead of doing this for every test*() method, we can just add a single @requires in the test class' docblock.

wim leers’s picture

Assigned: wim leers » Unassigned
Related issues: +#3001542: Update Guzzle from 6.3.0 to 6.3.3
StatusFileSize
new3.44 KB
new77.39 KB

And the changes that #138 made to the FileUploader service have been confirmed to be unnecessary after a painful digging deeper (made simpler by @gabesullice!): https://github.com/guzzle/guzzle/issues/1882 changed an implementation detail in Guzzle 6.3.2, and 8.7 updated to 6.3.3 in #3001542: Update Guzzle from 6.3.0 to 6.3.3, where they updated the sibling test method \Drupal\Tests\rest\Functional\FileUploadResourceTestBase::testPostFileUploadInvalidHeaders() to accommodate that change.

IOW: core updated its dependencies, which broke the test, which had been relying on a bug in Guzzle… 😵

So this interdiff reverts a portion of #138 and ports a portion of #3001542. Which means the FileUploader class is now no longer being customized.

wim leers’s picture

See #2940383-16: [META] Unify file upload logic of REST and JSON:API.

Which makes me wonder whether we should commit this to JSON:API 2.1. It introduces an extra blocker for JSON:API to get into core…

stillfinder’s picture

Related issues: -#3001542: Update Guzzle from 6.3.0 to 6.3.3

Hello everybody,

I'm trying to apply the patch https://www.drupal.org/files/issues/2019-01-08/2958554-139.patch , but when I run composer update, I get: "Could not apply patch! Skipping. The error was: Cannot apply patch https://www.drupal.org/files/issues/2019-01-08/2958554-139.patch". Am I doing anything wrong?

Thanks for any help!

P.S.: I've added the following lines to my composer.json:

"drupal/jsonapi": {
        "Allow creation of file entities from binary data via JSON API requests": "https://www.drupal.org/files/issues/2019-01-08/2958554-139.patch"
      }
wim leers’s picture

#144: Thanks for testing this new feature! 🙏 👍

  • You're applying #139. #139 applies cleanly to 8.x-2.0.
  • #140 applies cleanly to 8.x-2.x HEAD, which is currently ad50c207ce849b1a37946aa40073708eb340eb2d.
stillfinder’s picture

#145: Thank you for your response, but when I'm trying #140 I get the same error:

Could not apply patch! Skipping. The error was: Cannot apply patch https://www.drupal.org/files/issues/2019-01-09/2958554-140.patch

Added to composer:

"drupal/jsonapi": {
        "Allow creation of file entities from binary data via JSON API requests": "https://www.drupal.org/files/issues/2019-01-09/2958554-140.patch"
      }

Maybe I need another patch for my Drupal Version 8.5.7 ?
Also, my JSON API Version is 8.x-2.0-rc1

wim leers’s picture

Also, my JSON API Version is 8.x-2.0-rc1

This is your problem. RC1 is ancient. 2.0 was released on Monday. Update to that, and then use #139. That will work :)

stillfinder’s picture

#147 Thanks a lot for your answer, it helped to apply #139 patch. But I have faced with another problem - I'm getting 405 Method Not Allowed in response.

I have the user_picture(machine name) field, with the field type: Image. Maybe I miss something in my request. Or maybe it's because of my Drupal Version 8.5.7 ?

Request:
POST {{url}}/jsonapi/user/user/4d861a6a-8f69-45e5-a06b-9fd25b207cb9/user_picture

Content-Type: application/octet-stream
Accept: application/vnd.api+json
X-CSRF-Token:fPvqqR4UHrK_YuGO2oYHLkW-kKefmZMzuHqMGG0uYCI
cache-control: no-cache

Body: binary

Response

{
    "errors": [
        {
            "title": "Method Not Allowed",
            "status": 405,
            "detail": "No route found for \"POST /jsonapi/user/user/4d861a6a-8f69-45e5-a06b-9fd25b207cb9/user_picture\": Method Not Allowed (Allow: GET, HEAD)",
            "links": {
                "via": {
                    "href": "https://theoptimalme.local.thebrain4web.com/jsonapi/user/user/4d861a6a-8f69-45e5-a06b-9fd25b207cb9/user_picture"
                },
                "info": {
                    "href": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.6"
                }
            }
        }
    ],
    "jsonapi": {
        "version": "1.0",
        "meta": {
            "links": {
                "self": {
                    "href": "http://jsonapi.org/format/1.0/"
                }
            }
        }
    }
}

Thank you for any help.

gabesullice’s picture

@stillfinder, see comment #139 where we say that Drupal 8.5 is not supported. You'll have to update to Drupal 8.6. Running uncommitted patches can be a bit of a pain. If you get it working, we'd like to know how it works for you :)

stillfinder’s picture

#149 Thank you! It works with 8.6.
But, is there any older patch that works with Drupal 5.8 ?

wim leers’s picture

@stillfinder: No. This needs certain improvements in Drupal 8.5. Also, 8.5 only has a few more months of security support left. There's no good reason to build something new on 8.5 today. Any development you do should be on 8.6.

stillfinder’s picture

@Wim Leers - Thank you for your response!

stillfinder’s picture

Hi, I've found that after update the jsonapi from 2.0-rc1 to 2.0 and apply the patch #139 the following request {{url}}/jsonapi/user/user?filter[uid]=1&fields[user--user]=name returns the next:

{
    "errors": [
        {
            "title": "Bad Request",
            "status": 400,
            "detail": "Invalid nested filtering. The field `uid`, given in the path `uid`, does not exist.",
            "links": {
                "via": {
                    "href": "https://domain.com/jsonapi/user/user?fields%5Buser--user%5D=name&filter%5Buid%5D=1"
                },
                "info": {
                    "href": "http://www.w3.org/Protocols/rfc2616/rfc2616-sec10.html#sec10.4.1"
                }
            }
        }
    ],
    "jsonapi": {
        "version": "1.0",
        "meta": {
            "links": {
                "self": {
                    "href": "http://jsonapi.org/format/1.0/"
                }
            }
        }
    }
}

but before, the response was:

{
    "data": [
        {
            "type": "user--user",
            "id": "0a8c8f12-c592-41ce-801e-cc5c7e7e7bf5",
            "attributes": {
                "name": "Admin"
            },
            "links": {
                "self": {
                    "href": "https://domain.com/jsonapi/user/user/0a8c8f12-c592-41ce-801e-cc5c7e7e7bf5"
                }
            }
        }
    ],
...
wim leers’s picture

That is 100% unrelated to this issue. Please open a separate issue for that. Thanks! 🙏

stillfinder’s picture

@Wim Leers thank you, I'll do

wim leers’s picture

StatusFileSize
new6.1 KB
new75.25 KB

Status: Needs review » Needs work

The last submitted patch, 156: 2958554-156.patch, failed testing. View results

wim leers’s picture

Status: Needs work » Needs review
StatusFileSize
new76.03 KB

Oops. Incomplete patch.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

Green again on all three supported core branches, so back to RTBC.

  • Wim Leers committed bf2fc2a on 8.x-2.x
    Issue #2958554 by Wim Leers, gabesullice, malik.kotob, pfrilling,...
wim leers’s picture

Status: Reviewed & tested by the community » Fixed
StatusFileSize
new518 bytes

Based on feedback in chat with the other JSON:API maintainers:

gabesullice [7:48 PM]
I'm +1 to committing it.
e0ipso [9:30 PM]
I'm OK with what you decide :slightly_smiling_face:

And given that so many people seem to be applying and using this patch in production: #43 + #47 + #52 + #120 + #124 + #127 + #129 + #144 means at least 8 people are using this today, and we know for sure this is an underestimation.

Given maintainer support and strong demand, I think that outweighs my concern in #143. So let's do this!

steveworley’s picture

Hi I recently included this patch in a site I'm working on and am currently running into issues, I was wondering if anyone might be able to point me in the right direction.

I'm generating a CSRF token first with /rest/session/token

Then I'm sending a POST request to the file field which looks like:

curl -X POST \
http://d8.dev/jsonapi/media/image/field_image_file \
  -H 'Accept: application/vnd.api+json' \
  -H 'Cache-Control: no-cache' \
  -H 'Content-Type: application/octet-stream' \
  -H 'Content-Disposition: attachment; filename="con-amore.jpg"'
  -H 'Authorization: Basic xxxx' \
  -H 'X-CSRF-token: orUgAL8fGOlu7T3ydSK5NsWnzr2FbMGX7CV1n74eVqc' \
  --trace-ascii debug.txt \
  --data-binary @/Users/steveworley/Downloads/Con-Amore.jpg

The user that I'm trying it with is an administrator and has all permissions. The error I'm getting is:

The controller result claims to be providing relevant cache metadata, but leaked metadata was detected. Please ensure you are not rendering content too early. Returned object class: Drupal\\jsonapi\\ResourceResponse.

I'm sure I've just missed something.

One other question I had was - it looks that the field has to be "file" in Drupal and image fields are not supported. Just wondering if I missed something there as well.

Thanks for your help!

wim leers’s picture

Can you please open a new issue for that? Thanks! 🙏

wim leers’s picture

StatusFileSize
new6.48 KB

@steveworley opened #3026459: The controller result claims to be providing relevant cache metadata, but leaked metadata was detected and the problem he was seeing disappeared as he updated to the latest commit in the branch.

Also, the upstream #2940383: [META] Unify file upload logic of REST and JSON:API that is included here was updated based on reviews, see #2940383-23: [META] Unify file upload logic of REST and JSON:API. Let's update the committed forward compatibility layer to match that latest iteration. It also fixes a problem on environments with assertions turned on (all dev environments by default) where it'd forbid uploads to e.g. image fields due to an incorrect assertion.

  • Wim Leers committed 4bb799f on 8.x-2.x
    Issue #2958554 by Wim Leers: Update forward compatibility layer to #...
wim leers’s picture

#164 was green on 8.5+8.6+8.7. Committed.

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.

shenzhuxi’s picture

Is there anyone successfully create a file entity by POSTING file to file/file endpoint or update an existing file entity by PATCHING to file/file/uuid endpoint?
With "Content-Type: application/octet-stream", I got code "415"/error "Unsupported Media Type" for both POST and PATCH methods (8.x-2.x+core 8.6.x).

Server side log:
Symfony\Component\HttpKernel\Exception\UnsupportedMediaTypeHttpException: No route found that matches "Content-Type: application/octet-stream" in Drupal\Core\Routing\ContentTypeHeaderMatcher->filter() (line 49 of drupal8/core/lib/Drupal/Core/Routing/ContentTypeHeaderMatcher.php).

Tronux’s picture

Still having this issue in D8.8
Request
Content-Type | application/octet-stream
Content-Disposition | file; filename="Testing1.jpg"

Response: 415Unsupported Media Type

rpayanm’s picture

Project: JSON:API » Drupal core
Version: 8.x-2.x-dev » 8.9.x-dev
Component: Code » jsonapi.module

Moving to Drupal core's issue queue.

I'm working on https://www.drupal.org/project/drupal/issues/3122113