Repository: Kovah/LinkAce (language: php)

Issue:
Vague error message for 422 Unprocessable Content
I've been banging my head against the wall for the last few hours trying to figure this out:

![Image](https://github.com/user-attachments/assets/5f104751-c8c0-4436-80f0-d16502d6d869)

I think I narrowed it down to a missing Content-Length header, but it would be really, really nice if the error returned from the server was accurate. Not every tool out there automatically calculates that header. (Looking at you, Fetch API.)


Discussion / hints:
Someone had a similar issue here: https://github.com/Kovah/LinkAce/discussions/856#discussioncomment-11142173

Could you try to send the exact same request to the [LinkAce demo](https://demo.linkace.org)? The API key is available in the user settings when logged in.

Also, which version do you use?
I see the same behavior on the demo. If Content-Length is missing, you get the "URL is required" error.

<details>
<summary>Missing Content-Length</summary>
Request:

```
POST /api/v1/links HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer <sensitive>
Cache-Control: no-cache
Postman-Token: 98d8e94f-a68e-4fc8-badf-3974701b34b8
Host: demo.linkace.org
 
{
"url": "https://duckduckgo.com"
}
```

Response:
``` 
HTTP/1.1 422 Unprocessable Entity
Date: Wed, 29 Jan 2025 16:13:10 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
access-control-allow-origin: *
alt-svc: h3=":443"; ma=86400
Cache-Control: no-cache, private
x-ratelimit-limit: 60
x-ratelimit-remaining: 57
cf-cache-status: DYNAMIC
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=OLOp4%2BaGVh9aAYOAMjJl%2FShvgVUPoo9za9Fn7eoNAgJjRwDcKn%2FPfEjM8XHFMYLLF9ubtz9%2BGGRttkkICBjTZcp6wm8wXoL1kS2xusggwFtFQgqNSr%2FZp6MYujL4q%2BHzAOUS"}],"group":"cf-nel","max_age":604800}
NEL: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
Strict-Transport-Security: max-age=15552000; includeSubDomains; preload
Server: cloudflare
CF-RAY: 909a82091ffbacff-MSP
server-timing: cfL4;desc="?proto=TCP&rtt=18105&min_rtt=12435&rtt_var=8135&sent=13&recv=11&lost=0&retrans=0&sent_bytes=2215&recv_bytes=1221&delivery_rate=350428&cwnd=241&unsent_bytes=0&cid=a63414b278280261&ts=6353&x=0"
 
{"message":"The url field is required.","errors":{"url":["The url field is required."]}}
```
</details>


<details>
<summary>Same exact request with Content-Length</summary>
Request:

```
POST /api/v1/links HTTP/1.1
Accept: application/json
Content-Type: application/json
Authorization: Bearer <sensitive>
Cache-Control: no-cache
Postman-Token: cf30576f-cde5-4174-a5c3-af7a93f0f125
Host: demo.linkace.org
Content-Length: 41
 
{
"url": "https://duckduckgo.com"
}
```

Response:
```
HTTP/1.1 200 OK
Date: Wed, 29 Jan 2025 16:13:04 GMT
Content-Type: application/json
Transfer-Encoding: chunked
Connection: keep-alive
access-control-allow-origin: *
alt-svc: h3=":443"; ma=86400
Cache-Control: no-cache, private
x-content-type-options: nosniff
x-frame-options: SAMEORIGIN
x-ratelimit-limit: 60
x-ratelimit-remaining: 58
x-xss-protection: 1; mode=block
cf-cache-status: DYNAMIC
Report-To: {"endpoints":[{"url":"https:\/\/a.nel.cloudflare.com\/report\/v4?s=iNDSvzIytnxufV%2FXyS1jjrmBSbdQoXKrRe2hoIKPZonPmg%2BXe0kFPqXOBlSswlrNAiI3tdVU2GsxJ6whQ9h15vU00wY9BESbvuO64eCUYHK44dlrYWT4EJo%2FGPinn2U7HyKr"}],"group":"cf-nel","max_age":604800}
NEL: {"success_fraction":0,"report_to":"cf-nel","max_age":604800}
Strict-Transport-Security: max-age=15552000; includeSubDomains; preload
Server: cloudflare
CF-RAY: 909a81e339d9acff-MSP
server-timing: cfL4;desc="?proto=TCP&rtt=18906&min_rtt=14125&rtt_var=8712&sent=4&recv=4&lost=0&retrans=0&sent_bytes=203&recv_bytes=911&delivery_rate=103362&cwnd=238&unsent_bytes=0&cid=a63414b278280261&ts=426&x=0"
 
{"url":"https:\/\/duckduckgo.com","title":"DuckDuckGo - Your protection, our priority.","description":"The Internet privacy company that empowers you to seamlessly take control of your personal information online, without any tradeoffs.","user_id":1,"icon":"link","thumbnail":"https:\/\/duckduckgo.com\/assets\/logo_social-media.png","updated_at":"2025-01-29T16:13:04.000000Z","created_at":"2025-01-29T16:13:04.000000Z","id":89,"tags":[],"lists":[]}
```
</details>

I saw this issue on 1.15.6 on my production instance. Looks like the demo is currently using 1.15.5. And before I upgraded to 1.15.6 yesterday, I was using and experiencing the same issue on 1.12.1.
Could you try that again with the V2 demo? https://demo-v2.linkace.org/dashboard
The api token should be the same.

By the way: which tool do you use to make this request?
```bash
curl -vvv --request POST \
  --url http://my-local-linkace-2.app/api/v2/links \
  --header 'Authorization: Bearer 1|VQw1dO9s....' \
  --header 'accept: application/json' \
  --header 'content-type: application/json' \
  --data '{
  "url": "https://duckduckgo.com/updates"
}'
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host my-local-linkace-2.app:80 was resolved.
* IPv6: (none)
* IPv4: 192.168.100.125
*   Trying 192.168.100.125:80...
* Connected to my-local-linkace-2.app (192.168.100.125) port 80
> POST /api/v2/links HTTP/1.1
> Host: my-local-linkace-2.app
> User-Agent: curl/8.7.1
> Authorization: Bearer 1|VQw1dO9s....
> accept: application/json
> content-type: application/json
> Content-Length: 45
>
* upload completely sent off: 45 bytes
< HTTP/1.1 200 OK
< Date: Thu, 30 Jan 2025 12:38:06 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Vary: Authorization
< Cache-Control: no-cache, private
< X-RateLimit-Limit: 60
< X-RateLimit-Remaining: 58
< Access-Control-Allow-Origin: *
< Transfer-Encoding: chunked
< Content-Type: application/json
<
* Connection #0 to host my-local-linkace-2.app left intact
{"url":"https:\/\/duckduckgo.com\/updates","title":"What\u2019s New in DuckDuckGo","description":"We\u2019re constantly making improvements to our product. Every quarter, we post the best of our most recent updates here.","user_id":2,"icon":"link","thumbnail":"https:\/\/duckduckgo.com\/static-assets\/image\/pages\/updates\/meta.png","updated_at":"2025-01-30T12:38:07.000000Z","created_at":"2025-01-30T12:38:07.000000Z","id":394,"tags":[],"lists":[]}
```

however

```bash
curl -vvv --request POST \
  --url http://my-local-linkace-2.app/api/v2/links \
  --header 'Authorization: Bearer 1|VQw1dO9s....' \
  --header 'accept: application/json' \
  --header 'content-type: application/json' \
  --header "Content-Length:" \
  --data '{
  "url": "https://duckduckgogo.com"
}'
Note: Unnecessary use of -X or --request, POST is already inferred.
* Host my-local-linkace-2.app:80 was resolved.
* IPv6: (none)
* IPv4: 192.168.100.125
*   Trying 192.168.100.125:80...
* Connected to my-local-linkace-2.app (192.168.100.125) port 80
> POST /api/v2/links HTTP/1.1
> Host: linkace-apache.pmx.local
> User-Agent: curl/8.7.1
> Authorization: Bearer 1|VQw1dO9s...
> accept: application/json
> content-type: application/json
>
* upload completely sent off: 39 bytes
< HTTP/1.1 422 Unprocessable Content
< Date: Thu, 30 Jan 2025 12:46:03 GMT
< Server: Apache/2.4.58 (Ubuntu)
< Vary: Authorization
< Cache-Control: no-cache, private
< X-RateLimit-Limit: 60
< X-RateLimit-Remaining: 58
< Access-Control-Allow-Origin: *
< Transfer-Encoding: chunked
< Content-Type: application/json
<
* Leftovers after chunking: 498 bytes
* Connection #0 to host linkace-apache.pmx.local left intact
{"message":"The url field is required.","errors":{"url":["The url field is required."]}}
```

I think the behaviour is not entirely false in this context, as the error message is only sent as an addition to the actual issue: 422 Unprocessable Content

The client doing the request should be able to correctly send those requests, and content-length is at least for HTTP1.1 a required header when sending data and the transfer is not chunked.

Maybe there is a specific setting for the web server in front of LinkAce to handle this scenario, but I would not like to fix this edge case inside the application.
I've been using Postman to test some of these requests. Behavior is identical on the v2 demo.

I guess I got myself a bit confused yesterday focusing too much on the error message returned from LinkAce. I suppose I was expecting the message to explain the 422, which it technically doesn't, but I suppose is kind of technically correct as LinkAce must be trying to process an empty request or something.

I've mentioned it before, but I'm working on a firefox extension to save pages to LinkAce. Found some time to work on it again lately, and I'm not noticing my requests weren't identical from the extension vs my testing with Postman.

So, most of my trouble is stemming from not being a full-time JavaScript/TypeScript developer. I didn't realize the Fetch API doesn't automatically set the correct Content-Type headers, so the extension was sending `Content-Type: test/plain;charset=UTF-8`, which LinkAce doesn't want, hence the 422. Actually, in this example I think the server should return a 415 Unsupported Media Type, as the 422 Unprocessable Content suggests the server did understand the content-type, which is not true here.

Spent about 7 hours working on the extension yesterday and by the end of it my brain was apparently fried and I wasn't noticing all these details.

I maintain the opinion that the server should explain why it was unprocessable, and give a more accurate response status in the case of an incorrect Content-Type. There's a reason standards exist.

Worth noting there's also 411 Length Required, which should be returned if the server refuses to accept the request without a defined Content-Length, but most tools automatically calculate this, so this is a moot point. I don't know how much of this validation is configured in LinkAce vs being defined in the Framework.
I will have a look if such cases can easily handled properly.
I hope I'm not coming off unthankful or anything. I really do enjoy using LinkAce, I decided to stop using Pinboard and this is a fantastic replacement. 

Developing for the API has been a bit painful I have to admit. 

I whipped this up to see if it would help, and it kind of would, but this also feels completely incorrect now that I know those other HTTP statuses exist. Plus this would have to be duplicated on all endpoints, and that feels wrong, too!

https://github.com/prplecake/LinkAce/commit/dfe0639690e6865be3f60723e4466ab1568857ae
Hey, no worries. I am thankful for your input. 🙏  I have not yet worked with the LinkAce API beside doing some work via Zapier and with iOS shortcuts.
The commit looks interesting, I will check this and add it if everything works as expected.
I wonder if a Middleware would be a better place for it, and then it can apply to whatever routes it's configured for.

I wonder if something like this would be a better route to go:

```php
class ValidateContentTypeHeader
{
    public function handle($request, Closure $next)
    {
        if ($request->header("Content-Type") === "application/json") {
            return $next($request);
        }

        return Response::json("Content-Type should be 'application/json'.", 415);
    }
}
```

I might keep playing around with this, too.

Fix patch:
diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index b0d6f183..97e63b3f 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -43,6 +43,7 @@ class Kernel extends HttpKernel
         'api' => [
             // Throttling is configured in routes/api.php
             \Illuminate\Routing\Middleware\SubstituteBindings::class,
+            \App\Http\Middleware\ContentTypeHeaderValidationMiddleware::class,
         ],
     ];
 
diff --git a/app/Http/Middleware/ContentTypeHeaderValidationMiddleware.php b/app/Http/Middleware/ContentTypeHeaderValidationMiddleware.php
new file mode 100644
index 00000000..852cd0a3
--- /dev/null
+++ b/app/Http/Middleware/ContentTypeHeaderValidationMiddleware.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use Closure;
+use Illuminate\Http\Request;
+
+class ContentTypeHeaderValidationMiddleware
+{
+    /**
+     * Handle an incoming request.
+     * This middleware ensures that the Content-Type header is set to
+     * application/json, otherwise it will return a 415 Unsupported
+     * Media Type response.
+     *
+     * @param Request $request
+     * @param Closure $next
+     * @return mixed
+     */
+    public function handle(Request $request, Closure $next): mixed
+    {
+        $checkMethods = ['POST', 'PATCH'];
+        if (in_array($request->method(), $checkMethods) && $request->header('Content-Type') !== 'application/json') {
+            return response()->json([
+                'error' => 'Invalid Content-Type'
+            ], 415);
+        }
+
+        return $next($request);
+    }
+}
diff --git a/phpunit.xml b/phpunit.xml
index 760f6480..cf482359 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -20,6 +20,9 @@
     <testsuite name="Helper">
       <directory suffix="Test.php">./tests/Helper</directory>
     </testsuite>
+    <testsuite name="Middleware">
+      <directory suffix="Test.php">./tests/Middleware</directory>
+    </testsuite>
     <testsuite name="Migrations">
       <directory suffix="Test.php">./tests/Migrations</directory>
     </testsuite>


Test patch:
diff --git a/tests/Controller/API/LinkApiTest.php b/tests/Controller/API/LinkApiTest.php
index 7d986fca..7a2de573 100644
--- a/tests/Controller/API/LinkApiTest.php
+++ b/tests/Controller/API/LinkApiTest.php
@@ -152,6 +152,27 @@ public function test_full_create_request(): void
             ]);
     }
 
+    public function test_create_invalid_content_type_request(): void
+    {
+        $list = LinkList::factory()->create(['name' => 'Test List 1']);
+        $tag = Tag::factory()->create(['name' => 'a test 1']);
+        $tag2 = Tag::factory()->create(['name' => 'tag #2']);
+
+        $this->postJsonAuthorized('api/v2/links', [
+            'url' => 'https://example.com',
+            'title' => 'Search the Web',
+            'description' => 'There could be a description here',
+            'lists' => [$list->id],
+            'tags' => [$tag->id, $tag2->id],
+            'visibility' => 1,
+            'check_disabled' => false,
+        ], ['Content-Type' => 'application/xml'])
+            ->assertUnsupportedMediaType()
+            ->assertJson([
+                'error' => "Invalid Content-Type"
+            ]);
+    }
+
     public function test_create_request_with_list(): void
     {
         $list = LinkList::factory()->create(['name' => 'Test List 1']);
@@ -448,6 +469,25 @@ public function test_update_request(): void
         ])->assertForbidden();
     }
 
+    public function test_update_invalid_content_type_request(): void
+    {
+        $list = LinkList::factory()->create();
+        $this->createTestLinks();
+
+        $this->patchJsonAuthorized('api/v2/links/1', [
+            'url' => 'https://new-public-link.com',
+            'title' => 'Custom Title',
+            'description' => 'Custom Description',
+            'lists' => [$list->id],
+            'is_private' => false,
+            'check_disabled' => false,
+        ], ['Content-Type' => 'application/xml'])
+            ->assertUnsupportedMediaType()
+            ->assertJson([
+                'error' => 'Invalid Content-Type'
+            ]);
+    }
+
     public function test_invalid_update_request(): void
     {
         Link::factory()->create();
diff --git a/tests/Middleware/ContentTypeHeaderValidationMiddlewareTest.php b/tests/Middleware/ContentTypeHeaderValidationMiddlewareTest.php
new file mode 100644
index 00000000..dd385b88
--- /dev/null
+++ b/tests/Middleware/ContentTypeHeaderValidationMiddlewareTest.php
@@ -0,0 +1,22 @@
+<?php
+
+namespace Tests\Middleware;
+
+use App\Http\Middleware\ContentTypeHeaderValidationMiddleware;
+use Illuminate\Http\Request;
+use Tests\TestCase;
+
+class ContentTypeHeaderValidationMiddlewareTest extends TestCase
+{
+    public function testMissingContentTypeHeader(): void
+    {
+        $request = Request::create('/api/v1/links', 'POST');
+
+        $middleware = new ContentTypeHeaderValidationMiddleware();
+
+        $response = $middleware->handle($request, function () {
+        });
+
+        $this->assertEquals(415, $response->getStatusCode());
+    }
+}