Skip to content

Commit e6a5acd

Browse files
authored
Add OCI 1.1 subject, artifactType, and referrers API (#546)
This PR adds OCI Image Spec v1.1 artifact support. It extends Manifest, Index, and Descriptor with the `subject` and `artifactType` fields, adds a `referrers()` method to RegistryClient implementing the OCI Distribution Spec v1.1 referrers API. Also, I've added some unit unit tests for backward compatibility and roundtrip encpding of all new fields
1 parent 0f7e3a5 commit e6a5acd

File tree

5 files changed

+184
-3
lines changed

5 files changed

+184
-3
lines changed
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
//===----------------------------------------------------------------------===//
2+
// Copyright © 2026 Apple Inc. and the Containerization project authors.
3+
//
4+
// Licensed under the Apache License, Version 2.0 (the "License");
5+
// you may not use this file except in compliance with the License.
6+
// You may obtain a copy of the License at
7+
//
8+
// https://www.apache.org/licenses/LICENSE-2.0
9+
//
10+
// Unless required by applicable law or agreed to in writing, software
11+
// distributed under the License is distributed on an "AS IS" BASIS,
12+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
// See the License for the specific language governing permissions and
14+
// limitations under the License.
15+
//===----------------------------------------------------------------------===//
16+
17+
import AsyncHTTPClient
18+
import ContainerizationError
19+
import Foundation
20+
import NIOFoundationCompat
21+
22+
extension RegistryClient {
23+
/// Query the OCI referrers API for artifacts that reference a given manifest digest.
24+
///
25+
/// Implements `GET /v2/{name}/referrers/{digest}` from the OCI Distribution Spec v1.1.
26+
///
27+
/// - Parameters:
28+
/// - name: The repository name (e.g., "library/ubuntu").
29+
/// - digest: The digest of the subject manifest (e.g., "sha256:abc123...").
30+
/// - artifactType: Optional filter to return only referrers with a matching artifactType.
31+
/// - Returns: An `Index` whose `manifests` array contains descriptors of referring artifacts.
32+
/// Returns an empty index if the registry does not support the referrers API.
33+
public func referrers(name: String, digest: String, artifactType: String? = nil) async throws -> Index {
34+
var components = base
35+
components.path = "/v2/\(name)/referrers/\(digest)"
36+
37+
if let artifactType {
38+
components.queryItems = [URLQueryItem(name: "artifactType", value: artifactType)]
39+
}
40+
41+
let headers = [("Accept", MediaTypes.index)]
42+
43+
return try await request(components: components, method: .GET, headers: headers) { response in
44+
if response.status == .notFound {
45+
return Index(schemaVersion: 2, manifests: [])
46+
}
47+
48+
guard response.status == .ok else {
49+
let url = components.url?.absoluteString ?? "unknown"
50+
let reason = await ErrorResponse.fromResponseBody(response.body)?.jsonString
51+
throw Error.invalidStatus(url: url, response.status, reason: reason)
52+
}
53+
54+
let buffer = try await response.body.collect(upTo: self.bufferSize)
55+
return try JSONDecoder().decode(Index.self, from: buffer)
56+
}
57+
}
58+
}

Sources/ContainerizationOCI/Descriptor.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,21 @@ public struct Descriptor: Codable, Sendable, Equatable {
4242
/// This should only be used when referring to a manifest.
4343
public var platform: Platform?
4444

45+
/// artifactType specifies the IANA media type of the artifact.
46+
///
47+
/// Used in referrers API responses to indicate the type of each referring artifact.
48+
public let artifactType: String?
49+
4550
public init(
4651
mediaType: String, digest: String, size: Int64, urls: [String]? = nil, annotations: [String: String]? = nil,
47-
platform: Platform? = nil
52+
platform: Platform? = nil, artifactType: String? = nil
4853
) {
4954
self.mediaType = mediaType
5055
self.digest = digest
5156
self.size = size
5257
self.urls = urls
5358
self.annotations = annotations
5459
self.platform = platform
60+
self.artifactType = artifactType
5561
}
5662
}

Sources/ContainerizationOCI/Index.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,22 @@ public struct Index: Codable, Sendable {
3434
/// annotations contains arbitrary metadata for the image index.
3535
public var annotations: [String: String]?
3636

37+
/// `subject` references another manifest this index is an artifact of.
38+
public let subject: Descriptor?
39+
40+
/// `artifactType` specifies the IANA media type of the artifact this index represents.
41+
public let artifactType: String?
42+
3743
public init(
3844
schemaVersion: Int = 2, mediaType: String = MediaTypes.index, manifests: [Descriptor],
39-
annotations: [String: String]? = nil
45+
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
4046
) {
4147
self.schemaVersion = schemaVersion
4248
self.mediaType = mediaType
4349
self.manifests = manifests
4450
self.annotations = annotations
51+
self.subject = subject
52+
self.artifactType = artifactType
4553
}
4654

4755
public init(from decoder: Decoder) throws {
@@ -50,5 +58,7 @@ public struct Index: Codable, Sendable {
5058
self.mediaType = try container.decodeIfPresent(String.self, forKey: .mediaType) ?? ""
5159
self.manifests = try container.decode([Descriptor].self, forKey: .manifests)
5260
self.annotations = try container.decodeIfPresent([String: String].self, forKey: .annotations)
61+
self.subject = try container.decodeIfPresent(Descriptor.self, forKey: .subject)
62+
self.artifactType = try container.decodeIfPresent(String.self, forKey: .artifactType)
5363
}
5464
}

Sources/ContainerizationOCI/Manifest.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,14 +36,22 @@ public struct Manifest: Codable, Sendable {
3636
/// `annotations` contains arbitrary metadata for the image manifest.
3737
public let annotations: [String: String]?
3838

39+
/// `subject` references another manifest this manifest is an artifact of.
40+
public let subject: Descriptor?
41+
42+
/// `artifactType` specifies the IANA media type of the artifact this manifest represents.
43+
public let artifactType: String?
44+
3945
public init(
4046
schemaVersion: Int = 2, mediaType: String = MediaTypes.imageManifest, config: Descriptor, layers: [Descriptor],
41-
annotations: [String: String]? = nil
47+
annotations: [String: String]? = nil, subject: Descriptor? = nil, artifactType: String? = nil
4248
) {
4349
self.schemaVersion = schemaVersion
4450
self.mediaType = mediaType
4551
self.config = config
4652
self.layers = layers
4753
self.annotations = annotations
54+
self.subject = subject
55+
self.artifactType = artifactType
4856
}
4957
}

Tests/ContainerizationOCITests/OCIImageTests.swift

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
//
1818

19+
import Foundation
1920
import Testing
2021

2122
@testable import ContainerizationOCI
@@ -36,6 +37,30 @@ struct OCITests {
3637

3738
#expect(descriptor.platform?.architecture == "arm64")
3839
#expect(descriptor.platform?.os == "linux")
40+
#expect(descriptor.artifactType == nil)
41+
}
42+
43+
@Test func descriptorWithArtifactType() throws {
44+
let testArtifactType = "application/vnd.example.test.v1+json"
45+
let descriptor = ContainerizationOCI.Descriptor(
46+
mediaType: MediaTypes.imageManifest,
47+
digest: "sha256:abc123",
48+
size: 1234,
49+
artifactType: testArtifactType
50+
)
51+
#expect(descriptor.artifactType == testArtifactType)
52+
53+
let data = try JSONEncoder().encode(descriptor)
54+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: data)
55+
#expect(decoded.artifactType == testArtifactType)
56+
}
57+
58+
@Test func descriptorWithoutArtifactTypeDecodesAsNil() throws {
59+
let json = """
60+
{"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":0}
61+
"""
62+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Descriptor.self, from: json.data(using: .utf8)!)
63+
#expect(decoded.artifactType == nil)
3964
}
4065

4166
@Test func index() {
@@ -47,6 +72,37 @@ struct OCITests {
4772

4873
let index = ContainerizationOCI.Index(schemaVersion: 1, manifests: descriptors)
4974
#expect(index.manifests.count == 5)
75+
#expect(index.subject == nil)
76+
#expect(index.artifactType == nil)
77+
}
78+
79+
@Test func indexWithSubjectAndArtifactType() throws {
80+
let testArtifactType = "application/vnd.example.test.v1+json"
81+
let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:subject", size: 512)
82+
let index = ContainerizationOCI.Index(
83+
schemaVersion: 2,
84+
manifests: [],
85+
subject: subject,
86+
artifactType: testArtifactType
87+
)
88+
#expect(index.subject?.digest == "sha256:subject")
89+
#expect(index.artifactType == testArtifactType)
90+
91+
let data = try JSONEncoder().encode(index)
92+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: data)
93+
#expect(decoded.subject?.digest == "sha256:subject")
94+
#expect(decoded.artifactType == testArtifactType)
95+
}
96+
97+
@Test func indexDecodesWithoutNewFields() throws {
98+
let json = """
99+
{"schemaVersion":2,"manifests":[{"mediaType":"application/vnd.oci.descriptor.v1+json","digest":"sha256:abc","size":10}]}
100+
"""
101+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Index.self, from: json.data(using: .utf8)!)
102+
#expect(decoded.schemaVersion == 2)
103+
#expect(decoded.manifests.count == 1)
104+
#expect(decoded.subject == nil)
105+
#expect(decoded.artifactType == nil)
50106
}
51107

52108
@Test func manifests() {
@@ -61,5 +117,48 @@ struct OCITests {
61117
let manifest = ContainerizationOCI.Manifest(schemaVersion: 1, config: config, layers: descriptors)
62118
#expect(manifest.config.digest == "123")
63119
#expect(manifest.layers.count == 5)
120+
#expect(manifest.subject == nil)
121+
#expect(manifest.artifactType == nil)
122+
}
123+
124+
@Test func manifestWithSubjectAndArtifactType() throws {
125+
let testArtifactType = "application/vnd.example.test.v1+json"
126+
let config = ContainerizationOCI.Descriptor(mediaType: MediaTypes.emptyJSON, digest: "sha256:empty", size: 2)
127+
let subject = ContainerizationOCI.Descriptor(mediaType: MediaTypes.imageManifest, digest: "sha256:target", size: 1234)
128+
let layer = ContainerizationOCI.Descriptor(
129+
mediaType: testArtifactType,
130+
digest: "sha256:meta",
131+
size: 89,
132+
annotations: ["org.opencontainers.image.title": "metadata.json"]
133+
)
134+
135+
let manifest = ContainerizationOCI.Manifest(
136+
config: config,
137+
layers: [layer],
138+
subject: subject,
139+
artifactType: testArtifactType
140+
)
141+
#expect(manifest.subject?.digest == "sha256:target")
142+
#expect(manifest.artifactType == testArtifactType)
143+
#expect(manifest.layers[0].annotations?["org.opencontainers.image.title"] == "metadata.json")
144+
145+
let data = try JSONEncoder().encode(manifest)
146+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: data)
147+
#expect(decoded.subject?.digest == "sha256:target")
148+
#expect(decoded.artifactType == testArtifactType)
149+
}
150+
151+
@Test func manifestDecodesWithoutNewFields() throws {
152+
let json = """
153+
{
154+
"schemaVersion": 2,
155+
"config": {"mediaType":"application/vnd.oci.empty.v1+json","digest":"sha256:abc","size":2},
156+
"layers": []
157+
}
158+
"""
159+
let decoded = try JSONDecoder().decode(ContainerizationOCI.Manifest.self, from: json.data(using: .utf8)!)
160+
#expect(decoded.schemaVersion == 2)
161+
#expect(decoded.subject == nil)
162+
#expect(decoded.artifactType == nil)
64163
}
65164
}

0 commit comments

Comments
 (0)