@ancplua/qyl-api-schema 11.6.0 → 12.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/common/errors.tsp CHANGED
@@ -1,251 +1,224 @@
1
1
  // =============================================================================
2
- // qyl product API error models
2
+ // qyl product API problem details (RFC 9457)
3
3
  // =============================================================================
4
- // Standardized error responses following RFC 7807 Problem Details.
4
+ // Every error body is an RFC 9457 problem: the five standard members plus the
5
+ // qyl extension member `code`. A problem type is named twice, by its `type` URI
6
+ // and by its `code`, and each error model below fixes both, so a client branches
7
+ // on either and never on `title` or `detail`.
8
+ //
9
+ // Bodies carry no HTTP headers. The media type is a property of the body type
10
+ // (`@mediaTypeHint`), and a header an error response carries is declared on the
11
+ // operations whose server sends it: `X-Trace-Id` on the collector's 500
12
+ // (api/routes.tsp) and `WWW-Authenticate` on the workbench's 401
13
+ // (api/workbench.tsp).
5
14
  // =============================================================================
6
15
 
7
16
  import "@typespec/http";
8
- import "./types.tsp";
9
17
 
10
18
  using TypeSpec.Http;
11
19
 
12
20
  namespace Qyl.Api.Contracts.Common.Errors;
13
21
 
22
+ @doc("""
23
+ Problem type URI, the RFC 9457 `type` member: one per problem a qyl server
24
+ reports. `code` names the same problem type.
25
+ """)
26
+ enum ProblemType {
27
+ validationFailed: "https://qyl.at/problems/validation-failed",
28
+ unauthorized: "https://qyl.at/problems/unauthorized",
29
+ forbidden: "https://qyl.at/problems/forbidden",
30
+ notFound: "https://qyl.at/problems/not-found",
31
+ conflict: "https://qyl.at/problems/conflict",
32
+ preconditionFailed: "https://qyl.at/problems/precondition-failed",
33
+ preconditionRequired: "https://qyl.at/problems/precondition-required",
34
+ internalError: "https://qyl.at/problems/internal-error",
35
+ badGateway: "https://qyl.at/problems/bad-gateway",
36
+ serviceUnavailable: "https://qyl.at/problems/service-unavailable",
37
+ }
38
+
39
+ @doc("""
40
+ Stable machine-readable code of a problem type, the qyl `code` extension
41
+ member. `type` names the same problem type as a URI.
42
+ """)
43
+ enum ProblemCode {
44
+ validationFailed: "validation_failed",
45
+ unauthorized: "unauthorized",
46
+ forbidden: "forbidden",
47
+ notFound: "not_found",
48
+ conflict: "conflict",
49
+ preconditionFailed: "precondition_failed",
50
+ preconditionRequired: "precondition_required",
51
+ internalError: "internal_error",
52
+ badGateway: "bad_gateway",
53
+ serviceUnavailable: "service_unavailable",
54
+ }
55
+
14
56
  // =============================================================================
15
- // RFC 7807 Problem Details Base
57
+ // RFC 9457 Problem Details Base
16
58
  // =============================================================================
17
59
 
18
- @doc("RFC 7807 Problem Details for HTTP APIs")
60
+ @doc("RFC 9457 problem details, served as application/problem+json: the standard members plus the qyl `code` extension.")
61
+ @mediaTypeHint("application/problem+json")
19
62
  @error
20
63
  model ProblemDetails {
21
- @doc("RFC 7807 problem-details response media type")
22
- @header contentType: "application/problem+json";
64
+ @doc("URI of the problem type.")
65
+ type: ProblemType;
23
66
 
24
- @doc("A URI reference identifying the problem type")
25
- @encodedName("application/json", "type")
26
- problemType: url = "about:blank";
27
-
28
- @doc("A short, human-readable summary of the problem type")
67
+ @doc("Short summary of the problem type; the same for every occurrence.")
29
68
  title: string;
30
69
 
31
- @doc("The HTTP status code (informational only, actual code set by subtype)")
70
+ @doc("HTTP status code of this response, repeated for the reader of the body.")
32
71
  status: int32;
33
72
 
34
- @doc("A human-readable explanation specific to this occurrence")
73
+ @doc("Explanation specific to this occurrence.")
35
74
  detail?: string;
36
75
 
37
- @doc("A URI reference identifying the specific occurrence")
76
+ @doc("URI reference identifying this occurrence.")
38
77
  instance?: url;
39
78
 
40
- @doc("Trace ID for correlation")
41
- @encodedName("application/json", "trace_id")
42
- @header("X-Trace-Id")
43
- traceId?: TraceId;
44
-
45
- @doc("Request ID for support")
46
- @encodedName("application/json", "request_id")
47
- @header("X-Request-Id")
48
- requestId?: string;
49
-
50
- @doc("Timestamp of the error")
51
- timestamp?: utcDateTime;
79
+ @doc("Stable code of the problem type.")
80
+ code: ProblemCode;
52
81
  }
53
82
 
54
83
  // =============================================================================
55
- // Specific Error Types
84
+ // Problem Types
56
85
  // =============================================================================
57
86
 
58
- @doc("Resource not found (404)")
59
- @error
60
- model NotFoundError extends ProblemDetails {
61
- @statusCode _: 404;
62
- title: "Not Found";
63
-
64
- @doc("The type of resource that was not found")
65
- @encodedName("application/json", "resource_type")
66
- resourceType?: string;
67
-
68
- @doc("The identifier that was not found")
69
- @encodedName("application/json", "resource_id")
70
- resourceId?: string;
71
- }
72
-
73
- @doc("Bad request - validation failed (400)")
87
+ @doc("The request failed validation (400).")
74
88
  @error
75
89
  model ValidationError extends ProblemDetails {
76
90
  @statusCode _: 400;
91
+ type: ProblemType.validationFailed;
92
+ code: ProblemCode.validationFailed;
77
93
  title: "Validation Failed";
78
94
 
79
- @doc("List of validation errors")
95
+ @doc("Each rule the request broke.")
80
96
  errors: ValidationErrorDetail[];
81
97
  }
82
98
 
83
- @doc("Individual validation error detail")
99
+ @doc("One rule a request broke.")
84
100
  model ValidationErrorDetail {
85
- @doc("The field/property that failed validation")
101
+ @doc("The field or parameter that failed validation.")
86
102
  field: string;
87
103
 
88
- @doc("The error message")
104
+ @doc("What is wrong with it.")
89
105
  message: string;
90
106
 
91
- @doc("The validation rule that failed")
107
+ @doc("The validation rule that failed.")
92
108
  code: string;
93
109
 
94
- @doc("The rejected value (if safe to include)")
110
+ @doc("The rejected value, when it is safe to repeat.")
95
111
  @encodedName("application/json", "rejected_value")
96
112
  rejectedValue?: string;
97
113
  }
98
114
 
99
- @doc("Unauthorized - authentication required (401)")
115
+ @doc("The request carries no valid credential (401).")
100
116
  @error
101
117
  model UnauthorizedError extends ProblemDetails {
102
118
  @statusCode _: 401;
119
+ type: ProblemType.unauthorized;
120
+ code: ProblemCode.unauthorized;
103
121
  title: "Unauthorized";
104
-
105
- @doc("Authentication scheme expected")
106
- @header("WWW-Authenticate")
107
- @encodedName("application/json", "www_authenticate")
108
- wwwAuthenticate?: string;
109
122
  }
110
123
 
111
- @doc("Forbidden - insufficient permissions (403)")
124
+ @doc("The credential is valid but not allowed to do this (403).")
112
125
  @error
113
126
  model ForbiddenError extends ProblemDetails {
114
127
  @statusCode _: 403;
128
+ type: ProblemType.forbidden;
129
+ code: ProblemCode.forbidden;
115
130
  title: "Forbidden";
116
131
 
117
- @doc("Required permission that is missing")
132
+ @doc("The permission the credential lacks.")
118
133
  @encodedName("application/json", "required_permission")
119
134
  requiredPermission?: string;
120
135
  }
121
136
 
122
- @doc("Conflict - resource state conflict (409)")
137
+ @doc("The addressed resource does not exist (404).")
138
+ @error
139
+ model NotFoundError extends ProblemDetails {
140
+ @statusCode _: 404;
141
+ type: ProblemType.notFound;
142
+ code: ProblemCode.notFound;
143
+ title: "Not Found";
144
+
145
+ @doc("The kind of resource that was not found.")
146
+ @encodedName("application/json", "resource_type")
147
+ resourceType?: string;
148
+
149
+ @doc("The identifier that was not found.")
150
+ @encodedName("application/json", "resource_id")
151
+ resourceId?: string;
152
+ }
153
+
154
+ @doc("The request conflicts with the current state of a resource (409).")
123
155
  @error
124
156
  model ConflictError extends ProblemDetails {
125
157
  @statusCode _: 409;
158
+ type: ProblemType.conflict;
159
+ code: ProblemCode.conflict;
126
160
  title: "Conflict";
127
161
 
128
- @doc("The conflicting resource ID")
162
+ @doc("The identifier of the conflicting resource.")
129
163
  @encodedName("application/json", "conflicting_resource")
130
164
  conflictingResource?: string;
131
165
  }
132
166
 
133
- @doc("Precondition failed - the If-Match entity tag is no longer current (412)")
167
+ @doc("The If-Match entity tag is no longer current (412).")
134
168
  @error
135
169
  model PreconditionFailedError extends ProblemDetails {
136
170
  @statusCode _: 412;
171
+ type: ProblemType.preconditionFailed;
172
+ code: ProblemCode.preconditionFailed;
137
173
  title: "Precondition Failed";
138
174
 
139
- @doc("Entity tag of the current version, when the caller may read it")
175
+ @doc("Entity tag of the current version, when the caller may read it.")
140
176
  @encodedName("application/json", "current_etag")
141
177
  currentEtag?: string;
142
178
  }
143
179
 
144
- @doc("Precondition required - the request must send If-Match (428)")
180
+ @doc("The request must send If-Match (428).")
145
181
  @error
146
182
  model PreconditionRequiredError extends ProblemDetails {
147
183
  @statusCode _: 428;
184
+ type: ProblemType.preconditionRequired;
185
+ code: ProblemCode.preconditionRequired;
148
186
  title: "Precondition Required";
149
187
 
150
- @doc("The header the request must carry")
188
+ @doc("The header the request must carry.")
151
189
  @encodedName("application/json", "required_header")
152
190
  requiredHeader?: "If-Match";
153
191
  }
154
192
 
155
- @doc("Rate limited - too many requests (429)")
156
- @error
157
- model RateLimitError extends ProblemDetails {
158
- @statusCode _: 429;
159
- title: "Too Many Requests";
160
-
161
- @doc("Seconds until the rate limit resets")
162
- @header("Retry-After")
163
- @encodedName("application/json", "retry_after")
164
- retryAfter: int32;
165
-
166
- @doc("Maximum requests allowed per window")
167
- @header("X-RateLimit-Limit")
168
- @encodedName("application/json", "rate_limit")
169
- rateLimit?: int32;
170
-
171
- @doc("Remaining requests in current window")
172
- @header("X-RateLimit-Remaining")
173
- @encodedName("application/json", "rate_limit_remaining")
174
- rateLimitRemaining?: int32;
175
- }
176
-
177
- @doc("Internal server error (500)")
193
+ @doc("The server failed while handling the request (500).")
178
194
  @error
179
195
  model InternalServerError extends ProblemDetails {
180
196
  @statusCode _: 500;
197
+ type: ProblemType.internalError;
198
+ code: ProblemCode.internalError;
181
199
  title: "Internal Server Error";
182
-
183
- @doc("Error code for support reference")
184
- @encodedName("application/json", "error_code")
185
- errorCode?: string;
186
200
  }
187
201
 
188
- @doc("Upstream dependency failed while fulfilling the request (502)")
202
+ @doc("An upstream dependency failed while the server handled the request (502).")
189
203
  @error
190
204
  model BadGatewayError extends ProblemDetails {
191
205
  @statusCode _: 502;
206
+ type: ProblemType.badGateway;
207
+ code: ProblemCode.badGateway;
192
208
  title: "Bad Gateway";
193
209
 
194
- @doc("Stable dependency category; never contains raw exception text")
210
+ @doc("Stable dependency category; never raw exception text.")
195
211
  dependency?: string;
196
212
  }
197
213
 
198
- @doc("Service unavailable (503)")
214
+ @doc("The server cannot take the request at present (503).")
199
215
  @error
200
216
  model ServiceUnavailableError extends ProblemDetails {
201
217
  @statusCode _: 503;
218
+ type: ProblemType.serviceUnavailable;
219
+ code: ProblemCode.serviceUnavailable;
202
220
  title: "Service Unavailable";
203
221
 
204
- @doc("Expected availability time")
205
- @header("Retry-After")
206
- @encodedName("application/json", "retry_after")
207
- retryAfter?: int32;
208
-
209
- @doc("Reason for unavailability")
222
+ @doc("Why the server is unavailable.")
210
223
  reason?: "maintenance" | "overloaded" | "dependency_failure";
211
224
  }
212
-
213
- // =============================================================================
214
- // Error Response Union
215
- // =============================================================================
216
-
217
- @doc("Standard error response types")
218
- union ApiError {
219
- @doc("Resource not found")
220
- notFound: NotFoundError,
221
-
222
- @doc("Validation failed")
223
- validation: ValidationError,
224
-
225
- @doc("Authentication required")
226
- unauthorized: UnauthorizedError,
227
-
228
- @doc("Permission denied")
229
- forbidden: ForbiddenError,
230
-
231
- @doc("Resource conflict")
232
- conflict: ConflictError,
233
-
234
- @doc("Entity tag no longer current")
235
- preconditionFailed: PreconditionFailedError,
236
-
237
- @doc("If-Match missing")
238
- preconditionRequired: PreconditionRequiredError,
239
-
240
- @doc("Rate limit exceeded")
241
- rateLimit: RateLimitError,
242
-
243
- @doc("Server error")
244
- serverError: InternalServerError,
245
-
246
- @doc("Upstream dependency failed")
247
- badGateway: BadGatewayError,
248
-
249
- @doc("Service unavailable")
250
- unavailable: ServiceUnavailableError,
251
- }
@@ -1,7 +1,11 @@
1
1
  // =============================================================================
2
- // Qyl API pagination templates
2
+ // Qyl API cursor pagination
3
3
  // =============================================================================
4
- // Reusable pagination patterns for list operations.
4
+ // Every list operation pages the same way: it is a `@list` operation, spreads
5
+ // `CursorPageQuery` (one page-size rule, one cursor parameter) and answers a
6
+ // page whose items carry `@pageItems` and whose opaque `Cursor` carries
7
+ // `@continuationToken`. The cursor is a token the client passes back, never a
8
+ // link it follows, and the last page omits it.
5
9
  // =============================================================================
6
10
 
7
11
  import "@typespec/http";
@@ -11,244 +15,37 @@ using TypeSpec.Http;
11
15
 
12
16
  namespace Qyl.Api.Contracts.Common.Pagination;
13
17
 
14
- // =============================================================================
15
- // Cursor-Based Pagination (Preferred for Large Datasets)
16
- // =============================================================================
18
+ @doc("Page size of a list operation: at least 1 and at most 1000 items.")
19
+ @minValue(1)
20
+ @maxValue(1000)
21
+ scalar PageLimit extends int32;
17
22
 
18
- @doc("Cursor-based pagination parameters")
19
- model CursorPaginationParams {
20
- @doc("Opaque cursor for fetching the next page")
23
+ // The query parameters of every list operation. An alias, not a model, so it
24
+ // reaches the wire only as the parameters of the operations that spread it.
25
+ alias CursorPageQuery = {
26
+ @doc("Page size; 100 when omitted.")
21
27
  @query
22
- cursor?: string;
23
-
24
- @doc("Maximum number of items to return (1-1000)")
25
- @query
26
- @minValue(1)
27
- @maxValue(1000)
28
- limit?: int32 = 100;
28
+ @pageSize
29
+ limit?: PageLimit = 100;
29
30
 
30
- @doc("Sort direction")
31
+ @doc("The `next_cursor` of the previous page of the same query; omitted for the first page.")
31
32
  @query
32
- order?: SortOrder = SortOrder.desc;
33
- }
34
-
35
- @doc("Sort order")
36
- enum SortOrder {
37
- @doc("Ascending order (oldest first)")
38
- asc,
39
-
40
- @doc("Descending order (newest first)")
41
- desc,
42
- }
33
+ @continuationToken
34
+ cursor?: Cursor;
35
+ };
43
36
 
44
- @doc("Cursor-based paginated response wrapper")
37
+ @doc("One page of a list operation.")
45
38
  model CursorPage<T> {
46
- @doc("List of items in this page")
39
+ @doc("The items of this page.")
47
40
  @pageItems
48
41
  items: T[];
49
42
 
50
- @doc("Cursor for the next page (null if no more pages)")
43
+ @doc("Cursor of the next page, passed back as the `cursor` query parameter. Absent on the last page.")
51
44
  @encodedName("application/json", "next_cursor")
52
- @nextLink
53
- nextCursor?: string;
54
-
55
- @doc("Cursor for the previous page (null if first page)")
56
- @encodedName("application/json", "prev_cursor")
57
- prevCursor?: string;
58
-
59
- @doc("Whether there are more items available")
60
- @encodedName("application/json", "has_more")
61
- hasMore: boolean;
62
- }
63
-
64
- // =============================================================================
65
- // Offset-Based Pagination (For Small/Medium Datasets)
66
- // =============================================================================
67
-
68
- @doc("Offset-based pagination parameters")
69
- model OffsetPaginationParams {
70
- @doc("Page number (1-indexed)")
71
- @query
72
- @pageIndex
73
- @minValue(1)
74
- page?: int32 = 1;
75
-
76
- @doc("Items per page (1-1000)")
77
- @query("page_size")
78
- @pageSize
79
- @minValue(1)
80
- @maxValue(1000)
81
- @encodedName("application/json", "page_size")
82
- pageSize?: int32 = 100;
83
- }
84
-
85
- @doc("Offset-based paginated response wrapper")
86
- model OffsetPage<T> {
87
- @doc("List of items in this page")
88
- @pageItems
89
- items: T[];
90
-
91
- @doc("Current page number")
92
- page: int32;
93
-
94
- @doc("Items per page")
95
- @encodedName("application/json", "page_size")
96
- pageSize: int32;
97
-
98
- @doc("Total number of items across all pages")
99
- @encodedName("application/json", "total_count")
100
- totalCount: int64;
101
-
102
- @doc("Total number of pages")
103
- @encodedName("application/json", "total_pages")
104
- totalPages: int32;
105
-
106
- @doc("Whether there is a next page")
107
- @encodedName("application/json", "has_next")
108
- hasNext: boolean;
109
-
110
- @doc("Whether there is a previous page")
111
- @encodedName("application/json", "has_prev")
112
- hasPrev: boolean;
113
- }
114
-
115
- // =============================================================================
116
- // Time-Range Pagination (For Time-Series Data)
117
- // =============================================================================
118
-
119
- @doc("Time-range based query parameters")
120
- model TimeRangeParams {
121
- @doc("Start of time range (inclusive)")
122
- @query("start_time")
123
- @encodedName("application/json", "start_time")
124
- startTime?: utcDateTime;
125
-
126
- @doc("End of time range (exclusive)")
127
- @query("end_time")
128
- @encodedName("application/json", "end_time")
129
- endTime?: utcDateTime;
130
-
131
- @doc("Maximum number of items to return")
132
- @query
133
- @minValue(1)
134
- @maxValue(10000)
135
- limit?: int32 = 1000;
136
- }
137
-
138
- @doc("Time-range paginated response with continuation")
139
- model TimeRangePage<T> {
140
- @doc("List of items in this page")
141
- @pageItems
142
- items: T[];
143
-
144
- @doc("Actual start time of returned data")
145
- @encodedName("application/json", "start_time")
146
- startTime: utcDateTime;
147
-
148
- @doc("Actual end time of returned data")
149
- @encodedName("application/json", "end_time")
150
- endTime: utcDateTime;
151
-
152
- @doc("Continuation token for next time window")
153
- @encodedName("application/json", "continuation_token")
154
45
  @continuationToken
155
- continuationToken?: string;
46
+ nextCursor?: Cursor;
156
47
 
157
- @doc("Whether there is more data in the time range")
48
+ @doc("Whether more items follow this page. `next_cursor` is present exactly when this is true.")
158
49
  @encodedName("application/json", "has_more")
159
50
  hasMore: boolean;
160
-
161
- @doc("Number of items returned")
162
- count: int32;
163
- }
164
-
165
- // =============================================================================
166
- // Streaming Pagination (For Real-Time Data)
167
- // =============================================================================
168
-
169
- @doc("Streaming parameters for SSE endpoints")
170
- model StreamParams {
171
- @doc("Include historical data from this timestamp")
172
- @query
173
- since?: utcDateTime;
174
-
175
- @doc("Filter by trace ID")
176
- @query("trace_id")
177
- @encodedName("application/json", "trace_id")
178
- traceId?: TraceId;
179
-
180
- @doc("Filter by service name")
181
- @query("service_name")
182
- @encodedName("application/json", "service_name")
183
- serviceName?: string;
184
-
185
- @doc("Maximum events per second (rate limiting)")
186
- @query("max_events_per_second")
187
- @encodedName("application/json", "max_events_per_second")
188
- @minValue(1)
189
- @maxValue(1000)
190
- maxEventsPerSecond?: int32 = 100;
191
- }
192
-
193
- @doc("SSE event wrapper for streaming")
194
- model StreamEvent<T> {
195
- @doc("Event ID for resumption")
196
- id: string;
197
-
198
- @doc("Event type")
199
- @encodedName("application/json", "event_type")
200
- eventType: string;
201
-
202
- @doc("Event timestamp")
203
- timestamp: utcDateTime;
204
-
205
- @doc("Event data payload")
206
- data: T;
207
-
208
- @doc("Retry interval hint in milliseconds")
209
- retry?: int32;
210
- }
211
-
212
- // =============================================================================
213
- // Aggregation Parameters
214
- // =============================================================================
215
-
216
- @doc("Time bucket size for aggregations")
217
- enum TimeBucket {
218
- @doc("1 minute buckets")
219
- minute: "1m",
220
-
221
- @doc("5 minute buckets")
222
- fiveMinutes: "5m",
223
-
224
- @doc("15 minute buckets")
225
- fifteenMinutes: "15m",
226
-
227
- @doc("1 hour buckets")
228
- hour: "1h",
229
-
230
- @doc("1 day buckets")
231
- day: "1d",
232
-
233
- @doc("1 week buckets")
234
- week: "1w",
235
-
236
- @doc("Auto-select based on time range")
237
- auto: "auto",
238
- }
239
-
240
- @doc("Aggregation query parameters")
241
- model AggregationParams {
242
- @doc("Time bucket size")
243
- @query
244
- bucket?: TimeBucket = TimeBucket.auto;
245
-
246
- @doc("Group by these dimensions")
247
- @query("group_by")
248
- @encodedName("application/json", "group_by")
249
- groupBy?: string[];
250
-
251
- @doc("Filter expression")
252
- @query
253
- filter?: string;
254
51
  }
@@ -32,13 +32,6 @@ scalar ResourceId extends string;
32
32
  @maxLength(128)
33
33
  scalar ETag extends string;
34
34
 
35
- @doc("""
36
- Opaque pagination cursor. Pass the `next_cursor` of one page to receive the
37
- next page of the same query. Its content is not part of the contract and
38
- must not be parsed or built by a client.
39
- """)
40
- scalar Cursor extends string;
41
-
42
35
  @doc("""
43
36
  Collector API key roles. Every key sent in `x-otlp-api-key` carries one or
44
37
  more roles: `ingest` accepts OTLP, `read` serves the query routes, `write`
package/common/types.tsp CHANGED
@@ -159,10 +159,18 @@ scalar Temperature extends float64;
159
159
  @maxLength(64)
160
160
  scalar Sha256Hash extends string;
161
161
 
162
+ @doc("""
163
+ Opaque pagination cursor. Pass the `next_cursor` of one page to receive the
164
+ next page of the same query. Its content is not part of the contract and
165
+ must not be parsed or built by a client.
166
+ """)
167
+ scalar Cursor extends string;
168
+
162
169
  // The revision is a canonical digest of the semantic OpenAPI projection and the
163
170
  // exported telemetry-key projection, not a package version. Presentation-only
164
- // fields do not change it. Truncated to 16 hex characters because it is compared
165
- // for equality, never used as a security boundary.
171
+ // fields and the release version stamped into `info.version` do not change it.
172
+ // Truncated to 16 hex characters because it is compared for equality, never used
173
+ // as a security boundary.
166
174
  @doc("Deterministic revision of the generated API contract: 'sha256:' followed by the first 16 hex characters of its canonical semantic digest.")
167
175
  @pattern("^sha256:[a-f0-9]{16}$")
168
176
  @minLength(23)