Repository: rclone/rclone (language: go)

Issue:
Wrong Content-Type in HTTP API
#### The associated forum post URL from `https://forum.rclone.org`
https://forum.rclone.org/t/wrong-content-type-in-http-api/45420


#### What is the problem you are having with rclone?
The [HTTP API](https://rclone.org/rc/#api-http) always returns the Content-Type `text/plain; charset=utf-8` instead of `application/json`.


#### What is your rclone version (output from `rclone version`)
```
rclone v1.66.0
- os/version: alpine 3.19.1 (64 bit)
- os/kernel: 5.15.0-101-generic (x86_64)
- os/type: linux
- os/arch: amd64
- go/version: go1.22.1
- go/linking: static
- go/tags: none
```


#### Which OS you are using and how many bits (e.g. Windows 7, 64 bit)
Windows and Linux (Docker), both 64 bit


#### Which cloud storage system are you using? (e.g. Google Drive)
Not relevant


#### The command you were trying to run (e.g. `rclone copy /tmp remote:tmp`)
`curl -v -X POST http://localhost:5572/core/stats`


#### A log from the command with the `-vv` flag (e.g. output from `rclone -vv copy /tmp remote:tmp`)
```
rclone    | 2024/04/03 22:49:45 DEBUG : rclone: Version "v1.66.0" starting with parameters ["rclone" "rcd" "--rc-addr" ":5572" "--rc-no-auth" "-vv"]
rclone    | 2024/04/03 22:49:45 NOTICE: Serving remote control on http://[::]:5572/
rclone    | 2024/04/03 22:49:56 DEBUG : rc: "core/stats": with parameters map[]
rclone    | 2024/04/03 22:49:56 DEBUG : rc: "core/stats": reply map[bytes:0 checks:0 deletedDirs:0 deletes:0 elapsedTime:10.985591033 errors:0 eta:<nil> fatalError:false renames:0 retryError:false serverSideCopies:0 serverSideCopyBytes:0 serverSideMoveBytes:0 serverSideMoves:0 speed:0 totalBytes:0 totalChecks:0 totalTransfers:0 transferTime:0 transfers:0]: <nil>
```


<!--- Please keep the note below for others who read your bug report. -->

#### How to use GitHub

* Please use the 👍 [reaction](https://blog.github.com/2016-03-10-add-reactions-to-pull-requests-issues-and-comments/) to show that you are affected by the same issue.
* Please don't comment if you have no relevant information to add. It's just extra noise for everyone subscribed to this issue.
* Subscribe to receive notifications on status change and new comments.



Discussion / hints:
Do you want to have a go at fixing this @nullinger ?
Hm,

I think that you are overestimating my Go knowledge 😟

The Content-Type seems to be set here but I don’t know where the function is called:
https://github.com/rclone/rclone/blob/339bc1d1a3d7beb57d854712ac00f473819c519d/lib/rest/rest.go#L264
Maybe @KDreynolds would like to have a go at this? It will end up being a ~~one~~ two line change, but which line to change is the problem!

I think anywhere you see `rc.WriteJSON` in this file (there are two places) should probably set the `Content-Type` just before. Ideally we should check this in the tests too.

https://github.com/rclone/rclone/blob/339bc1d1a3d7beb57d854712ac00f473819c519d/fs/rc/rcserver/rcserver.go#L204
Sounds good, I'll take a crack at this. Thanks!

Fix patch:
diff --git a/fs/rc/rcserver/rcserver.go b/fs/rc/rcserver/rcserver.go
index 21378e8ec5f82..ae355a757fb99 100644
--- a/fs/rc/rcserver/rcserver.go
+++ b/fs/rc/rcserver/rcserver.go
@@ -200,6 +200,7 @@ func (s *Server) Serve() error {
 func writeError(path string, in rc.Params, w http.ResponseWriter, err error, status int) {
 	fs.Errorf(nil, "rc: %q: error: %v", path, err)
 	params, status := rc.Error(path, in, err, status)
+	w.Header().Set("Content-Type", "application/json")
 	w.WriteHeader(status)
 	err = rc.WriteJSON(w, params)
 	if err != nil {
@@ -294,6 +295,7 @@ func (s *Server) handlePost(w http.ResponseWriter, r *http.Request, path string)
 	}
 
 	fs.Debugf(nil, "rc: %q: reply %+v: %v", path, out, err)
+	w.Header().Set("Content-Type", "application/json")
 	err = rc.WriteJSON(w, out)
 	if err != nil {
 		// can't return the error at this point - but have a go anyway


Test patch:
diff --git a/fs/rc/rcserver/rcserver_test.go b/fs/rc/rcserver/rcserver_test.go
index 47ec89e588c21..66009becdf5dd 100644
--- a/fs/rc/rcserver/rcserver_test.go
+++ b/fs/rc/rcserver/rcserver_test.go
@@ -3,6 +3,7 @@ package rcserver
 import (
 	"bytes"
 	"context"
+	"encoding/json"
 	"fmt"
 	"io"
 	"net/http"
@@ -147,7 +148,11 @@ func testServer(t *testing.T, tests []testRun, opt *rc.Options) {
 			body, err := io.ReadAll(resp.Body)
 			require.NoError(t, err)
 
-			if test.Contains == nil {
+			if test.ContentType == "application/json" && test.Expected != "" {
+				expectedNormalized := normalizeJSON(t, test.Expected)
+				actualNormalized := normalizeJSON(t, string(body))
+				assert.Equal(t, expectedNormalized, actualNormalized, "Normalized JSON does not match")
+			} else if test.Contains == nil {
 				assert.Equal(t, test.Expected, string(body))
 			} else {
 				assert.True(t, test.Contains.Match(body), fmt.Sprintf("body didn't match: %v: %v", test.Contains, string(body)))
@@ -847,3 +852,49 @@ func TestServeModTime(t *testing.T) {
 	}}
 	testServer(t, tests, &opt)
 }
+
+func TestContentTypeJSON(t *testing.T) {
+	tests := []testRun{
+		{
+			Name:        "Check Content-Type for JSON response",
+			URL:         "rc/noop",
+			Method:      "POST",
+			Body:        `{}`,
+			ContentType: "application/json",
+			Status:      http.StatusOK,
+			Expected:    "{}\n",
+			Headers: map[string]string{
+				"Content-Type": "application/json",
+			},
+		},
+		{
+			Name:        "Check Content-Type for JSON error response",
+			URL:         "rc/error",
+			Method:      "POST",
+			Body:        `{}`,
+			ContentType: "application/json",
+			Status:      http.StatusInternalServerError,
+			Expected: `{
+				"error": "arbitrary error on input map[]",
+				"input": {},
+				"path": "rc/error",
+				"status": 500
+			}
+			`,
+			Headers: map[string]string{
+				"Content-Type": "application/json",
+			},
+		},
+	}
+	opt := newTestOpt()
+	testServer(t, tests, &opt)
+}
+
+func normalizeJSON(t *testing.T, jsonStr string) string {
+	var jsonObj map[string]interface{}
+	err := json.Unmarshal([]byte(jsonStr), &jsonObj)
+	require.NoError(t, err, "JSON unmarshalling failed")
+	normalizedJSON, err := json.Marshal(jsonObj)
+	require.NoError(t, err, "JSON marshalling failed")
+	return string(normalizedJSON)
+}