Skip to content

fix content-type declared by /events API#50953

Merged
thaJeztah merged 1 commit intomoby:masterfrom
ndeloof:ndjson
Oct 3, 2025
Merged

fix content-type declared by /events API#50953
thaJeztah merged 1 commit intomoby:masterfrom
ndeloof:ndjson

Conversation

@ndeloof
Copy link
Contributor

@ndeloof ndeloof commented Sep 11, 2025

- What I did

fixed /events API to report content-type as application/x-ndjson
using application/json in this API is wrong, I assume client always ignore Content-Type set here otherwise they would only unmarshal the first object in the stream and miss all others.

There's unfortunately no official mime type for line-delimited json object streams

- How I did it

- How to verify it

- Human readable description for the release notes

`events` API now reports content-type as `application/x-ndjson` for newline-delimited JSON event stream

- A picture of a cute animal (not mandatory but encouraged)
image

@akerouanton
Copy link
Member

Now that the api is a separate module, we need to re-vendor changes made in the api folder to make them available to the daemon module. That's why the CI is currently failing to build your PR. (You just need to run go mod vendor.)

You also need to update the content type declared in the Swagger file:

moby/api/swagger.yaml

Lines 10351 to 10352 in 3278393

produces:
- "application/json"

And add a note in the API changelog: https://github.com/moby/moby/blob/master/api/docs/CHANGELOG.md

@ndeloof
Copy link
Contributor Author

ndeloof commented Sep 11, 2025

You also need to update the content type declared in the Swagger file:

stupid me, this is the reason I've been looking into this, and eventually forgot to fix 😅

@thaJeztah
Copy link
Member

This probably also requires changes in the swagger definition to declare the content-types it can produce.

I recall you had another PR related to delimited JSON responses, but that one used application/json-seq - I think application/json-seq was an RFC / formal standard, and application/x-ndjson not an official one (also using the x- prefix which is now discouraged / deprecated)

@ndeloof
Copy link
Contributor Author

ndeloof commented Sep 13, 2025

@thaJeztah application/json-seq isn't actually a newline delimited JSON stream.
Json-seq uses a record separator (ASCII 0x1E) to allow multi-line json objects, while nd-json just assumes json object matches a single line. Could be interesting API uses Accept header to select which one to use, but this is out of the scope for this PR (see With golang/go#19307)

Media type used here is the one recommended by https://github.com/ndjson/ndjson-spec?tab=readme-ov-file#33-mediatype-and-file-extensions

@ndeloof
Copy link
Contributor Author

ndeloof commented Sep 17, 2025

I just noticed we already rely on unofficial x- mime types for TAR

@thaJeztah thaJeztah added this to the 29.0.0 milestone Sep 18, 2025
@thompson-shaun thompson-shaun moved this from New to Open in 🔦 Maintainer spotlight Sep 18, 2025
@thaJeztah
Copy link
Member

I just noticed we already rely on unofficial x- mime types for TAR

Yes, we debated at the time; I think before that we used application/octet-stream, because there is no official mediaType for tar (someone should probably register one in the IANA); application/x-tar is closest, and considered a "common" mediaType; https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/MIME_types/Common_types

(I think using x- prefixes as "intermediate, not-yet-official" mediatype is discouraged nowadays, as moving from non-official to official would cause things to diverge; and adding a x- prefix without going through the official bodies to register would still have the chance to conflict with other uses).

Could be interesting API uses Accept header to select which one to use, but this is out of the scope for this PR (see With golang/go#19307)

Oh! I missed that this is (finally!) getting some traction; I think technically we don't have to wait for that proposal to land; we already have some code in place (using the very old gddo dependency, but it's doing it's job fine);

func writeCompressedResponse(w http.ResponseWriter, r *http.Request, body io.Reader) error {
var cw io.Writer
switch gddohttputil.NegotiateContentEncoding(r, []string{"gzip", "deflate"}) {

Digging a bit deeper in the options we have; during the maintainers call, @corhere also contributed another option (use SSE as a response), which I'll add below.

application/json-seq (RFC7464)

RFC7464 (application/json-seq) is a more robust, because the record-separator cannot appear in JSON documents; this also (technically) would allow us to return pretty-printed JSON (not something we likely want though), or to split large JSON structs (which COULD be interesting for some cases, such as the log streams). With RFC7464, newlines (LF) are optional (but recommended), so it would look like;

<RS><JSON document><LF>
<RS><JSON document><LF>

With the option for long records to;

<RS>JSON document (partial)
JSON document (partial)
JSON document (partial)
JSON document (partial)<LF>
<RS>JSON document (partial)
JSON document (partial)
JSON document (partial)
JSON document (partial)<LF>

So, ultimately it looks to be more solid, but ⚠️ it's not compatible with application/x-ndjson - clients trying to handle the response would still be able to use the LF-delimited response, but would (likely) fail to handle the record-separator (RS) and consider it part of the JSON, so malformed JSON.

Use Server-Sent Events (SSE) (text/event-stream)

This was opted as an alternative by @corhere; he coined using Server-Sent Events (SSE), and possibly using text/event-stream+json as mediaType; I did some searching, but as far as I could find, the +json would be non-standard, and likely break interoperability (a ;charset=utf-8 is allowed though). So if we would decide (in future) to go this path, we would probably need to stick with a plain text/event-stream, and address the "but the data is JSON" either through documentation, or with an additional (custom) header to indicate the format of the data. IFF we implement this in future, we probably should add such a header, so that we can switch between data-formats when needed.

SSE may be interesting for som of our uses; they also allow splitting messages over multiple data fields (potentially useful for large (JSON) responses), and the event type could be a match to some things we already produce.

Use application/x-ndjson

That's what's implemented in this PR; it's indeed for the current state of things the most appropriate mediatype. I'm concerned about unconditionally using this mediaType though;

  • We can gate it by API version API < 1.52 produce application/json and API > 1.52 produce application/x-ndjson
  • Which technically should be the client's responsibility to know they can expect a different mediatype if they decide to support API 1.52
  • But, it's possible that clients have code to expect application/json (and may produce errors if not), and not all clients may be explicitly selecting an API version. That's for sure a mistake on the client, but could still cause outfall, and may still be considered a breaking change at the API level.
  • Switching the mediaType just based on API version may also be limiting ourselves if we would want to switch between options mentioned.
  • 👉 I think we should consider adding content-type negotiation;
    • send either application/json or application/x-ndjson header based on the Accept header, and a 406 Not Acceptable if an unsupported content-type is requested.
    • clients that don't send the header would continue to receive the wrong mediatype, but they were already handling this, so nothing should break
    • clients that do send a Accept header can get application/x-ndjson as response.
    • if we extend the options in future to add either application/json-seq or text/event-stream, we can add those options without breaking backward compatibility, and can even decide to add them regardless of API version (as the content-negotiation would be leading for clients to detect whether the option is supported if we return a 406)

@thaJeztah
Copy link
Member

I want to have a look at (until the Go proposal lands) adding basic content negotiation similar to

func writeCompressedResponse(w http.ResponseWriter, r *http.Request, body io.Reader) error {
var cw io.Writer
switch gddohttputil.NegotiateContentEncoding(r, []string{"gzip", "deflate"}) {
, as that would (per the above) keep further options open; from a quick look it should probably (famous last words) not be too complicated, and

@ndeloof
Copy link
Contributor Author

ndeloof commented Sep 19, 2025

content negotiation is the best option AFAICT, as this would prevent need to guard this by API version and risk for a non-compliant client to see a sudden failure to parse output.
I didn't noticed gddo was already a dependency, can use it here so a client with Accept: application/x-ndjson set will get the adequate Content-Type header set in response, while an existing client will just get the same old (but incorrect :P) application/json. I also can look into support for json-seq as a follow-up PR

@thaJeztah
Copy link
Member

Oh, yes, if you have time to play; that sounds great! ❤️ - if not, it felt like a fun thing to play with in the weekend

Using gddo should be fine as long as the code is in the moby/v2 module (not in the client/ or api/ packages), as we don't really want it as a dependency for others, but for the daemon, that should all be fine.

can use it here so a client with Accept: application/x-ndjson se

As application/x-ndjson is clearly the correct content type, we can probably make the client already set it as an accept header for functions that use this / these endpoint(s). It can set an accept header for both application/x-ndjson and application/json to be "correct" (although older daemons wouldn't care).

I also can look into support for json-seq as a follow-up PR

Yup, I think that one is a "nice to have", and may require slightly more changes, but it sounds like a good option to have; depending on what language/framework is used, perhaps one is more common than the other (and vice-versa), and given that the differences are "small", we can probably support both with minimal changes.

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

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

Just some quick comments from glancing over

MediaTypeMultiplexedStream = "application/vnd.docker.multiplexed-stream"

// MediaTypeJson is the MIME-Type for JSON objects
MediaTypeJson = "application/json"
Copy link
Member

Choose a reason for hiding this comment

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

nit (and to prevent linters from complaining);

Suggested change
MediaTypeJson = "application/json"
MediaTypeJSON = "application/json"

@thaJeztah
Copy link
Member

build was failing because 2 const references weren't updated in your last push; I did a quick rebase and fix for those to have CI run.

produces:
- "application/json"
- "application/x-ndjson"
- "application/json-seq"
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should leverage RFC 8091 to leave room for negotiating the format of the event content by defining a dedicated media type for the event document with the structured syntax suffix for json-seq.

Suggested change
- "application/json-seq"
- "application/vnd.docker.system-events.v1+json-seq"

Copy link
Contributor Author

@ndeloof ndeloof Sep 23, 2025

Choose a reason for hiding this comment

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

👎 if content-type is expected to describe the JSON message being transported by HTTP, then each and every API endpoint would have to declare such a custom mime type (see https://datatracker.ietf.org/doc/html/rfc6839#section-3.1)

IMHO, even I don't expect most to require application/json Content-Type, this would introduce a breaking change in the API for consumers. This also makes content-type management harder to read/parse.

other (standard) options we have:

  • RFC 6906 profile Content-Type: application/json; profile=<URI for payload schema>
  • RFC 8288 Link Header Link: <URI for payload schema>; rel="describedby"

Copy link
Contributor

Choose a reason for hiding this comment

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

Fair enough. We already have the Engine API versioning scheme to describe the semantics of the documents.

Copy link
Member

Choose a reason for hiding this comment

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

Oh! Thought I commented, but yeah, I think adding additional mediaTypes to provide more granularity .. could be interesting, but is a bit out of scope for these initial steps. AFAICS, this PR doesn't prevent us from doing so in future if we want to, but the initial goal of this PR was to make sure the mediaTypes we return are aligning better with the implementation, and to use mediaTypes that are accepted by common framework / languages (not the default Go implementation we have).

Marking this thread as "unresolved" but only for discoverability / finding it back if we want to have that discussion in future.

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

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

LGTM

@@ -0,0 +1,97 @@
package internal
Copy link
Member

Choose a reason for hiding this comment

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

This could probably be in some sub package, but I don't have a good suggestion for a name, and it's all internal so .. fine for later if we come up with a good name

Comment on lines +43 to +44
headers.Add("Accept", types.MediaTypeJSONSequence)
headers.Add("Accept", types.MediaTypeNDJSON)
Copy link
Member

Choose a reason for hiding this comment

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

For others; double checked that Add appends to existing values (which it does). These functions always confuse me 😂

Comment on lines +301 to +305
contentType := httputil.NegotiateContentType(r, []string{
types.MediaTypeNDJSON,
types.MediaTypeJSONSequence,
}, types.MediaTypeJSON) // output isn't actually JSON but API used to this content-type
w.Header().Set("Content-Type", contentType)
Copy link
Member

Choose a reason for hiding this comment

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

For others; the third argument is the "fallback". Also checked that the order in which options are taken as priority if "no q / weight" is specified by the client, so ndjson is the preferred option, and json-sequence is used if the client "accepts" it.

We could consider making ndjson the default for new API versions and only fallback to application/json for old API versions, but we can still make that change in a follow-up.

produces:
- "application/json"
- "application/x-ndjson"
- "application/json-seq"
Copy link
Member

Choose a reason for hiding this comment

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

Oh! Thought I commented, but yeah, I think adding additional mediaTypes to provide more granularity .. could be interesting, but is a bit out of scope for these initial steps. AFAICS, this PR doesn't prevent us from doing so in future if we want to, but the initial goal of this PR was to make sure the mediaTypes we return are aligning better with the implementation, and to use mediaTypes that are accepted by common framework / languages (not the default Go implementation we have).

Marking this thread as "unresolved" but only for discoverability / finding it back if we want to have that discussion in future.

Comment on lines +29 to +30
reader io.Reader
buffer []byte
Copy link
Contributor

Choose a reason for hiding this comment

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

Would bufio.Reader simplify the implementation?

Copy link
Contributor

Choose a reason for hiding this comment

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

Is buffering even necessary? An io.Reader is not required to fill the destination slice. This passes the unit test:

func (r *RSFilterReader) Read(p []byte) (n int, err error) {
	if len(p) == 0 {
		return 0, nil
	}

	n, err = r.reader.Read(p)
	filtered := slices.DeleteFunc(p[:n], func(b byte) bool { return b == rs })
	return len(filtered), err
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

indeed, not sure why I came to this unnecessary complexity

@ndeloof ndeloof force-pushed the ndjson branch 2 times, most recently from a58f243 to 8dc7215 Compare October 3, 2025 06:48
Signed-off-by: Nicolas De Loof <nicolas.deloof@gmail.com>
@thaJeztah
Copy link
Member

did a rebase and re-vendor to make CI happy

@thaJeztah
Copy link
Member

Flaky test (should be unrelated)

=== Failed
=== FAIL: amd64.docker.docker.integration.network.bridge TestLegacyLink/no_link (0.18s)
    bridge_linux_test.go:713: assertion failed: string "Connecting to 172.18.0.2 (172.18.0.2:80)\nwget: server returned error: HTTP/1.0 404 Not Found\n" does not contain "download timed out"
    --- FAIL: TestLegacyLink/no_link (0.18s)

=== FAIL: amd64.docker.docker.integration.network.bridge TestLegacyLink (2.96s)

Copy link
Member

@thaJeztah thaJeztah left a comment

Choose a reason for hiding this comment

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

all green now 🎉

LGTM, thanks for updating!

@thaJeztah thaJeztah merged commit 73bacc6 into moby:master Oct 3, 2025
323 of 326 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Archived in project

Development

Successfully merging this pull request may close these issues.

5 participants