@stackonward/cli 1.6.0 → 1.8.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/dist/index.js +8 -8
- package/dist/index.js.map +1 -1
- package/dist/resources/components/nuxt/site-source/app/assets/css/site-shell.css +14 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/CallToAction.vue +3 -1
- package/dist/resources/components/nuxt/site-source/app/components/content/Faq.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/content/Reviews.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/components/steps/Showcase.vue +2 -2
- package/dist/resources/components/nuxt/site-source/app/composables/useWebPush.ts +4 -0
- package/dist/resources/components/nuxt/site-source/app/plugins/web-push.ts +52 -0
- package/dist/resources/components/nuxt/site-source/app/theme/button.ts +18 -22
- package/dist/resources/components/nuxt/site-source/app/theme/index.ts +24 -0
- package/dist/resources/components/nuxt/site-source/app/theme/input.ts +32 -17
- package/dist/resources/components/nuxt/site-source/app/theme/modal.ts +1 -1
- package/dist/resources/components/nuxt/site-source/app/theme/select.ts +62 -18
- package/dist/resources/components/nuxt/site-source/app/theme/textarea.ts +73 -1
- package/dist/resources/components/nuxt/site-source/app/web-push/worker.ts +8 -0
- package/dist/resources/components/registry.json +11 -1
- package/dist/resources/registry.json +15 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/architecture.md +1 -1
- package/dist/resources/templates/base/{.claude → .codex}/docs/index.md +3 -3
- package/dist/resources/templates/base/{.claude → .codex}/docs/verification.md +1 -1
- package/dist/resources/templates/base/AGENTS.md +24 -24
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/api-design.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/architecture-template.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/check-list.md +3 -3
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/database.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/error-handling.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/index.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/logging.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-onboarding-sop.md +2 -2
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/mediagen-provider-quick-reference.md +1 -1
- package/dist/resources/templates/go-backend/{.claude → .codex}/docs/module-standards.md +1 -1
- package/dist/resources/templates/go-backend/.dockerignore +1 -2
- package/dist/resources/templates/go-backend/AGENTS.md +19 -19
- package/dist/resources/templates/go-backend/README.md +5 -1
- package/dist/resources/templates/go-backend/deployments/onex/product-onboarding.example.json +201 -0
- package/dist/resources/templates/go-backend/docs/onex-notifications.md +85 -0
- package/dist/resources/templates/go-backend/docs/openapi-provenance.json +1 -1
- package/dist/resources/templates/go-backend/docs/runtime-provenance.json +23 -2
- package/dist/resources/templates/go-backend/docs/source-provenance.json +124 -6
- package/dist/resources/templates/go-backend/docs/standards-provenance.json +30 -30
- package/dist/resources/templates/go-backend/internal/common/middleware/cors.go +1 -0
- package/dist/resources/templates/go-backend/internal/common/middleware/middleware_test.go +1 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post.go +28 -0
- package/dist/resources/templates/go-backend/internal/core/httpclient/safe_post_test.go +56 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client.go +453 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/client_test.go +772 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/codec.go +33 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error.go +146 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/error_notification_test.go +29 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client.go +132 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/client_test.go +222 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/notification/contracts.go +219 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/resilience.go +23 -0
- package/dist/resources/templates/go-backend/internal/integrations/onex/v1/transport_policy.go +96 -0
- package/dist/resources/templates/go-backend/internal/products/README.md +1 -1
- package/dist/resources/templates/go-backend/internal/sharedevents/README.md +1 -1
- package/dist/resources/templates/go-backend/scripts/check-openapi-clean-deletion.sh +3 -3
- package/dist/resources/templates/mcp-server/.prettierignore +1 -0
- package/dist/resources/templates/nuxt/{.claude → .codex}/docs/stack.md +17 -1
- package/dist/resources/templates/nuxt/.dockerignore +1 -1
- package/dist/resources/templates/nuxt/.env.example +6 -0
- package/dist/resources/templates/nuxt/.prettierignore +2 -1
- package/dist/resources/templates/nuxt/DESIGN.md +55 -10
- package/dist/resources/templates/nuxt/README.md +8 -0
- package/dist/resources/templates/nuxt/nuxt.config.ts +13 -1
- package/dist/resources/templates/nuxt/package.json +4 -2
- package/package.json +1 -1
- package/dist/resources/templates/base/CLAUDE.md +0 -437
- package/dist/resources/templates/go-backend/CLAUDE.md +0 -1139
- /package/dist/resources/templates/base/{.claude → .codex}/docs/security-lifecycle.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/e2e-standards.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/event-runtime-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/identifier-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/openapi-contract.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/payment-gateway-onboarding.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/platform-boundary.md +0 -0
- /package/dist/resources/templates/go-backend/{.claude → .codex}/docs/quality-gates.md +0 -0
- /package/dist/resources/templates/mcp-server/{.claude → .codex}/docs/stack.md +0 -0
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
package v1
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"errors"
|
|
7
|
+
"fmt"
|
|
8
|
+
"mime"
|
|
9
|
+
"net/http"
|
|
10
|
+
"net/url"
|
|
11
|
+
"regexp"
|
|
12
|
+
"strconv"
|
|
13
|
+
"strings"
|
|
14
|
+
"time"
|
|
15
|
+
|
|
16
|
+
"{{module}}/internal/common/internalservice"
|
|
17
|
+
"{{module}}/internal/common/types"
|
|
18
|
+
"{{module}}/internal/core/httpclient"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
const (
|
|
22
|
+
maximumConfirmedObjectEncodedBytes = ((1 << 20) + 2) / 3 * 4
|
|
23
|
+
maximumResponseEnvelopeBytes = 4 << 10
|
|
24
|
+
defaultMaxResponseBytes = maximumConfirmedObjectEncodedBytes + maximumResponseEnvelopeBytes
|
|
25
|
+
maximumRequestIDBytes = 128
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
var productCodePattern = regexp.MustCompile(`^[a-z][a-z0-9_-]{1,63}$`)
|
|
29
|
+
|
|
30
|
+
// Config 定义 OneX internal v1 HTTP 边界的不可变运行参数。
|
|
31
|
+
type Config struct {
|
|
32
|
+
BaseURL string
|
|
33
|
+
ProductCode string
|
|
34
|
+
Timeout time.Duration
|
|
35
|
+
MaxResponseBytes int
|
|
36
|
+
SafeReadRetry SafeReadRetryConfig
|
|
37
|
+
OperationRetries map[string]int
|
|
38
|
+
RetryWaiter RetryWaiter
|
|
39
|
+
Dependency DependencyExecutor
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Call 描述一次带独立 deadline 的 OneX wire 请求。
|
|
43
|
+
type Call struct {
|
|
44
|
+
Method string
|
|
45
|
+
Path string
|
|
46
|
+
Query url.Values
|
|
47
|
+
UserID int64
|
|
48
|
+
RequestID string
|
|
49
|
+
IdempotencyKey string
|
|
50
|
+
Body any
|
|
51
|
+
ExpectedStatuses []int
|
|
52
|
+
RequiredResponseHeaders map[string]string
|
|
53
|
+
Timeout time.Duration
|
|
54
|
+
Dependency string
|
|
55
|
+
Operation string
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Client 统一执行 OneX internal v1 认证、可信头和严格响应解码。
|
|
59
|
+
type Client struct {
|
|
60
|
+
httpClient *httpclient.Client
|
|
61
|
+
signer internalservice.Signer
|
|
62
|
+
baseURL string
|
|
63
|
+
productCode string
|
|
64
|
+
maxResponseBytes int
|
|
65
|
+
defaultTimeout time.Duration
|
|
66
|
+
safeReadRetry SafeReadRetryConfig
|
|
67
|
+
operationRetries map[string]int
|
|
68
|
+
retryWaiter RetryWaiter
|
|
69
|
+
dependency DependencyExecutor
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// NewClient 构造不跟随重定向、仅按显式策略重试安全读取的 OneX v1 专用客户端。
|
|
73
|
+
func NewClient(client *httpclient.Client, signer internalservice.Signer, config Config) (*Client, error) {
|
|
74
|
+
if client == nil || signer == nil {
|
|
75
|
+
return nil, fmt.Errorf("onex v1 http client and signer are required")
|
|
76
|
+
}
|
|
77
|
+
baseURL, err := validateBaseURL(config.BaseURL)
|
|
78
|
+
if err != nil {
|
|
79
|
+
return nil, err
|
|
80
|
+
}
|
|
81
|
+
productCode := strings.TrimSpace(config.ProductCode)
|
|
82
|
+
if productCode == "" || productCode != config.ProductCode || !productCodePattern.MatchString(productCode) {
|
|
83
|
+
return nil, fmt.Errorf("onex v1 product code is invalid")
|
|
84
|
+
}
|
|
85
|
+
if config.Timeout < 0 {
|
|
86
|
+
return nil, fmt.Errorf("onex v1 timeout is invalid")
|
|
87
|
+
}
|
|
88
|
+
operationRetries := make(map[string]int, len(config.OperationRetries))
|
|
89
|
+
maximumRetries := config.SafeReadRetry.MaxRetries
|
|
90
|
+
for key, retries := range config.OperationRetries {
|
|
91
|
+
parts := strings.Split(key, ".")
|
|
92
|
+
if len(parts) != 2 {
|
|
93
|
+
return nil, fmt.Errorf("onex v1 operation retry key is invalid")
|
|
94
|
+
}
|
|
95
|
+
canonical, keyErr := dependencyOperationKey(parts[0], parts[1])
|
|
96
|
+
if keyErr != nil || canonical != key || retries < 0 || retries > maximumSafeReadRetries {
|
|
97
|
+
return nil, fmt.Errorf("onex v1 operation retry policy is invalid")
|
|
98
|
+
}
|
|
99
|
+
operationRetries[key] = retries
|
|
100
|
+
if retries > maximumRetries {
|
|
101
|
+
maximumRetries = retries
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
retryPolicy := config.SafeReadRetry
|
|
105
|
+
retryPolicy.MaxRetries = maximumRetries
|
|
106
|
+
if err := validateSafeReadRetryConfig(retryPolicy); err != nil {
|
|
107
|
+
return nil, err
|
|
108
|
+
}
|
|
109
|
+
if maximumRetries > 0 && config.Timeout <= 0 {
|
|
110
|
+
return nil, fmt.Errorf("onex v1 safe read retry requires a bounded timeout")
|
|
111
|
+
}
|
|
112
|
+
maxResponseBytes := config.MaxResponseBytes
|
|
113
|
+
if maxResponseBytes == 0 {
|
|
114
|
+
maxResponseBytes = defaultMaxResponseBytes
|
|
115
|
+
}
|
|
116
|
+
if maxResponseBytes < 1024 {
|
|
117
|
+
return nil, fmt.Errorf("onex v1 response limit is invalid")
|
|
118
|
+
}
|
|
119
|
+
derived := client.WithOptions(httpclient.Options{
|
|
120
|
+
Timeout: config.Timeout,
|
|
121
|
+
RedirectPolicy: httpclient.PreserveRedirectResponsePolicy(),
|
|
122
|
+
EnableLogging: false,
|
|
123
|
+
URLLogSanitizer: func(string) string { return "onex://internal/v1" },
|
|
124
|
+
})
|
|
125
|
+
derived.Resty().SetResponseBodyLimit(maxResponseBytes)
|
|
126
|
+
retryWaiter := config.RetryWaiter
|
|
127
|
+
if retryWaiter == nil {
|
|
128
|
+
retryWaiter = timerRetryWaiter{}
|
|
129
|
+
}
|
|
130
|
+
return &Client{
|
|
131
|
+
httpClient: derived,
|
|
132
|
+
signer: signer,
|
|
133
|
+
baseURL: baseURL,
|
|
134
|
+
productCode: productCode,
|
|
135
|
+
maxResponseBytes: maxResponseBytes,
|
|
136
|
+
defaultTimeout: config.Timeout,
|
|
137
|
+
safeReadRetry: config.SafeReadRetry,
|
|
138
|
+
operationRetries: operationRetries,
|
|
139
|
+
retryWaiter: retryWaiter,
|
|
140
|
+
dependency: config.Dependency,
|
|
141
|
+
}, nil
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Do 执行一次请求,并将成功响应严格解码到 out。
|
|
145
|
+
func (c *Client) Do(ctx context.Context, call Call, out any) error {
|
|
146
|
+
if c == nil {
|
|
147
|
+
return NewValidationError("onex_client_unavailable")
|
|
148
|
+
}
|
|
149
|
+
call.Query = cloneQueryValues(call.Query)
|
|
150
|
+
if err := validateCall(call, out); err != nil {
|
|
151
|
+
return err
|
|
152
|
+
}
|
|
153
|
+
requestCtx, cancel := operationContext(ctx, call.Timeout, c.defaultTimeout)
|
|
154
|
+
defer cancel()
|
|
155
|
+
if call.RequestID != "" && !validRequestID(call.RequestID) {
|
|
156
|
+
return NewValidationError("onex_request_id_invalid")
|
|
157
|
+
}
|
|
158
|
+
headers, err := c.signer.AuthHeaders(requestCtx)
|
|
159
|
+
if err != nil {
|
|
160
|
+
return mapAuthenticationError(requestCtx, err)
|
|
161
|
+
}
|
|
162
|
+
requestHeaders, err := canonicalServiceHeaders(headers)
|
|
163
|
+
if err != nil {
|
|
164
|
+
return err
|
|
165
|
+
}
|
|
166
|
+
requestHeaders["Content-Type"] = "application/json"
|
|
167
|
+
requestHeaders["Accept"] = "application/json"
|
|
168
|
+
requestHeaders[types.HeaderOneXProductCode] = c.productCode
|
|
169
|
+
if call.UserID > 0 {
|
|
170
|
+
requestHeaders[types.HeaderInternalUserID] = strconv.FormatInt(call.UserID, 10)
|
|
171
|
+
}
|
|
172
|
+
if call.RequestID != "" {
|
|
173
|
+
requestHeaders[types.HeaderRequestID] = call.RequestID
|
|
174
|
+
}
|
|
175
|
+
if call.IdempotencyKey != "" {
|
|
176
|
+
requestHeaders[types.HeaderIdempotencyKey] = call.IdempotencyKey
|
|
177
|
+
}
|
|
178
|
+
var body []byte
|
|
179
|
+
if call.Body != nil {
|
|
180
|
+
body, err = json.Marshal(call.Body)
|
|
181
|
+
if err != nil {
|
|
182
|
+
return &Error{Kind: ErrorKindValidation, Code: "onex_request_encoding_failed", cause: err}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
requestURL, err := url.Parse(c.baseURL + call.Path)
|
|
186
|
+
if err != nil {
|
|
187
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_request_url_invalid", cause: err}
|
|
188
|
+
}
|
|
189
|
+
requestURL.RawQuery = call.Query.Encode()
|
|
190
|
+
operation := func(executionCtx context.Context) error {
|
|
191
|
+
response, executionErr := c.execute(executionCtx, call, requestURL.String(), body, requestHeaders)
|
|
192
|
+
if executionErr != nil {
|
|
193
|
+
return mapTransportError(executionCtx, call.Method, executionErr)
|
|
194
|
+
}
|
|
195
|
+
if len(response.Body) > c.maxResponseBytes {
|
|
196
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_response_too_large", HTTPStatus: response.StatusCode}
|
|
197
|
+
}
|
|
198
|
+
if !containsStatus(call.ExpectedStatuses, response.StatusCode) {
|
|
199
|
+
return decodeHTTPError(response)
|
|
200
|
+
}
|
|
201
|
+
if headerErr := requireResponseHeaders(response.Headers, call.RequiredResponseHeaders); headerErr != nil {
|
|
202
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_response_headers_invalid", HTTPStatus: response.StatusCode, cause: headerErr}
|
|
203
|
+
}
|
|
204
|
+
if response.StatusCode == http.StatusNoContent {
|
|
205
|
+
if len(response.Body) != 0 {
|
|
206
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_response_contract_invalid", HTTPStatus: response.StatusCode}
|
|
207
|
+
}
|
|
208
|
+
return nil
|
|
209
|
+
}
|
|
210
|
+
if contentTypeErr := requireJSONContentType(response.Headers); contentTypeErr != nil {
|
|
211
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_response_content_type_invalid", HTTPStatus: response.StatusCode, cause: contentTypeErr}
|
|
212
|
+
}
|
|
213
|
+
if decodeErr := DecodeStrict(response.Body, out); decodeErr != nil {
|
|
214
|
+
return &Error{Kind: ErrorKindContract, Code: "onex_response_contract_invalid", HTTPStatus: response.StatusCode, cause: decodeErr}
|
|
215
|
+
}
|
|
216
|
+
return nil
|
|
217
|
+
}
|
|
218
|
+
if c.dependency == nil {
|
|
219
|
+
return operation(requestCtx)
|
|
220
|
+
}
|
|
221
|
+
key, err := dependencyOperationKey(call.Dependency, call.Operation)
|
|
222
|
+
if err != nil {
|
|
223
|
+
return NewContractError("onex_dependency_operation_invalid", err)
|
|
224
|
+
}
|
|
225
|
+
attempted, err := c.dependency.Execute(requestCtx, key, operation)
|
|
226
|
+
if err == nil {
|
|
227
|
+
return nil
|
|
228
|
+
}
|
|
229
|
+
if attempted {
|
|
230
|
+
return err
|
|
231
|
+
}
|
|
232
|
+
return &Error{Kind: ErrorKindUnavailable, Code: "onex_dependency_unavailable", Retryable: true, cause: err}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
func (c *Client) execute(ctx context.Context, call Call, requestURL string, body []byte, headers map[string]string) (*httpclient.Response, error) {
|
|
236
|
+
retryLimit := c.safeReadRetry.MaxRetries
|
|
237
|
+
if key, err := dependencyOperationKey(call.Dependency, call.Operation); err == nil {
|
|
238
|
+
if configured, exists := c.operationRetries[key]; exists {
|
|
239
|
+
retryLimit = configured
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for retryNumber := 0; ; retryNumber++ {
|
|
243
|
+
response, err := c.httpClient.DoOnceWithHeaders(ctx, call.Method, requestURL, body, nil, headers)
|
|
244
|
+
if call.Method != http.MethodGet || retryNumber >= retryLimit || !retryableSafeReadResult(ctx, response, err) {
|
|
245
|
+
return response, err
|
|
246
|
+
}
|
|
247
|
+
retryAfter := ""
|
|
248
|
+
if response != nil {
|
|
249
|
+
retryAfter = response.Headers.Get(types.HeaderRetryAfter)
|
|
250
|
+
}
|
|
251
|
+
delay := safeReadRetryDelay(c.safeReadRetry, retryNumber+1, retryAfter, time.Now().UTC())
|
|
252
|
+
if deadline, ok := ctx.Deadline(); ok && delay >= time.Until(deadline) {
|
|
253
|
+
return response, err
|
|
254
|
+
}
|
|
255
|
+
if waitErr := c.retryWaiter.Wait(ctx, delay); waitErr != nil {
|
|
256
|
+
return nil, waitErr
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
func retryableSafeReadResult(ctx context.Context, response *httpclient.Response, err error) bool {
|
|
262
|
+
if ctx.Err() != nil {
|
|
263
|
+
return false
|
|
264
|
+
}
|
|
265
|
+
if err != nil {
|
|
266
|
+
return true
|
|
267
|
+
}
|
|
268
|
+
return response != nil && shouldRetrySafeReadStatus(response.StatusCode)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
func operationContext(ctx context.Context, operationTimeout, defaultTimeout time.Duration) (context.Context, context.CancelFunc) {
|
|
272
|
+
timeout := operationTimeout
|
|
273
|
+
if timeout == 0 {
|
|
274
|
+
timeout = defaultTimeout
|
|
275
|
+
}
|
|
276
|
+
if timeout <= 0 {
|
|
277
|
+
return context.WithCancel(ctx)
|
|
278
|
+
}
|
|
279
|
+
return context.WithTimeout(ctx, timeout)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
func canonicalServiceHeaders(headers map[string]string) (map[string]string, error) {
|
|
283
|
+
if len(headers) != 1 {
|
|
284
|
+
return nil, NewContractError("onex_authentication_contract_invalid", nil)
|
|
285
|
+
}
|
|
286
|
+
authorization := ""
|
|
287
|
+
for name, value := range headers {
|
|
288
|
+
if !strings.EqualFold(name, "Authorization") {
|
|
289
|
+
return nil, NewContractError("onex_authentication_contract_invalid", nil)
|
|
290
|
+
}
|
|
291
|
+
authorization = value
|
|
292
|
+
}
|
|
293
|
+
const prefix = "Bearer "
|
|
294
|
+
if !strings.HasPrefix(authorization, prefix) || !validHeaderCredential(strings.TrimPrefix(authorization, prefix)) {
|
|
295
|
+
return nil, NewContractError("onex_authentication_contract_invalid", nil)
|
|
296
|
+
}
|
|
297
|
+
return map[string]string{"Authorization": authorization}, nil
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
func validHeaderCredential(value string) bool {
|
|
301
|
+
if value == "" || len(value) > 8192 {
|
|
302
|
+
return false
|
|
303
|
+
}
|
|
304
|
+
for index := range len(value) {
|
|
305
|
+
if value[index] < 0x21 || value[index] > 0x7e {
|
|
306
|
+
return false
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
return true
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
func validRequestID(value string) bool {
|
|
313
|
+
if value == "" || len(value) > maximumRequestIDBytes || value != strings.TrimSpace(value) {
|
|
314
|
+
return false
|
|
315
|
+
}
|
|
316
|
+
for index := range len(value) {
|
|
317
|
+
if value[index] < 0x21 || value[index] > 0x7e {
|
|
318
|
+
return false
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
return true
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
func mapAuthenticationError(ctx context.Context, err error) error {
|
|
325
|
+
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) {
|
|
326
|
+
return &Error{Kind: ErrorKindCanceled, Code: "onex_request_canceled", cause: err}
|
|
327
|
+
}
|
|
328
|
+
return &Error{Kind: ErrorKindUnavailable, Code: "onex_authentication_unavailable", Retryable: true, cause: err}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
func mapTransportError(ctx context.Context, method string, err error) error {
|
|
332
|
+
if errors.Is(ctx.Err(), context.Canceled) || errors.Is(err, context.Canceled) {
|
|
333
|
+
return &Error{Kind: ErrorKindCanceled, Code: "onex_request_canceled", cause: err}
|
|
334
|
+
}
|
|
335
|
+
if method != http.MethodGet {
|
|
336
|
+
return &Error{Kind: ErrorKindResultUnknown, Code: "onex_result_unknown", Retryable: true, cause: err}
|
|
337
|
+
}
|
|
338
|
+
return &Error{Kind: ErrorKindUnavailable, Code: "onex_request_unavailable", Retryable: true, cause: err}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func validateBaseURL(raw string) (string, error) {
|
|
342
|
+
value := strings.TrimRight(strings.TrimSpace(raw), "/")
|
|
343
|
+
parsed, err := url.Parse(value)
|
|
344
|
+
if err != nil || parsed.Host == "" || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
|
|
345
|
+
return "", fmt.Errorf("onex v1 base URL is invalid")
|
|
346
|
+
}
|
|
347
|
+
if parsed.EscapedPath() != "/internal/v1" {
|
|
348
|
+
return "", fmt.Errorf("onex v1 base URL must end at /internal/v1")
|
|
349
|
+
}
|
|
350
|
+
return value, nil
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func validateCall(call Call, out any) error {
|
|
354
|
+
if call.Method != http.MethodGet && call.Method != http.MethodPost && call.Method != http.MethodPut {
|
|
355
|
+
return NewValidationError("onex_method_invalid")
|
|
356
|
+
}
|
|
357
|
+
if !strings.HasPrefix(call.Path, "/") || strings.ContainsAny(call.Path, "?#") || strings.Contains(call.Path, "//") {
|
|
358
|
+
return NewValidationError("onex_path_invalid")
|
|
359
|
+
}
|
|
360
|
+
if err := validateQueryValues(call.Query); err != nil {
|
|
361
|
+
return NewValidationError("onex_query_invalid")
|
|
362
|
+
}
|
|
363
|
+
if call.UserID < 0 || strings.TrimSpace(call.RequestID) != call.RequestID || strings.TrimSpace(call.IdempotencyKey) != call.IdempotencyKey {
|
|
364
|
+
return NewValidationError("onex_trusted_context_invalid")
|
|
365
|
+
}
|
|
366
|
+
noContent := len(call.ExpectedStatuses) == 1 && call.ExpectedStatuses[0] == http.StatusNoContent
|
|
367
|
+
if len(call.ExpectedStatuses) == 0 || noContent && out != nil || !noContent && (out == nil || containsStatus(call.ExpectedStatuses, http.StatusNoContent)) {
|
|
368
|
+
return NewValidationError("onex_response_contract_required")
|
|
369
|
+
}
|
|
370
|
+
if call.Timeout < 0 || (call.Method == http.MethodGet && (call.Body != nil || call.IdempotencyKey != "")) {
|
|
371
|
+
return NewValidationError("onex_operation_policy_invalid")
|
|
372
|
+
}
|
|
373
|
+
if (call.Dependency == "") != (call.Operation == "") {
|
|
374
|
+
return NewValidationError("onex_dependency_operation_invalid")
|
|
375
|
+
}
|
|
376
|
+
if call.Dependency != "" {
|
|
377
|
+
if _, err := dependencyOperationKey(call.Dependency, call.Operation); err != nil {
|
|
378
|
+
return NewValidationError("onex_dependency_operation_invalid")
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return nil
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
func cloneQueryValues(values url.Values) url.Values {
|
|
385
|
+
if len(values) == 0 {
|
|
386
|
+
return nil
|
|
387
|
+
}
|
|
388
|
+
cloned := make(url.Values, len(values))
|
|
389
|
+
for key, items := range values {
|
|
390
|
+
cloned[key] = append([]string(nil), items...)
|
|
391
|
+
}
|
|
392
|
+
return cloned
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
func validateQueryValues(values url.Values) error {
|
|
396
|
+
if len(values) > 16 {
|
|
397
|
+
return fmt.Errorf("query contains too many keys")
|
|
398
|
+
}
|
|
399
|
+
totalValues := 0
|
|
400
|
+
for key, items := range values {
|
|
401
|
+
if key == "" || len(key) > 128 || key != strings.TrimSpace(key) || !validQueryText(key) {
|
|
402
|
+
return fmt.Errorf("query key is invalid")
|
|
403
|
+
}
|
|
404
|
+
totalValues += len(items)
|
|
405
|
+
if len(items) == 0 || totalValues > 64 {
|
|
406
|
+
return fmt.Errorf("query values are invalid")
|
|
407
|
+
}
|
|
408
|
+
for _, value := range items {
|
|
409
|
+
if len(value) > 1024 || !validQueryText(value) {
|
|
410
|
+
return fmt.Errorf("query value is invalid")
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if len(values.Encode()) > 8192 {
|
|
415
|
+
return fmt.Errorf("encoded query is too large")
|
|
416
|
+
}
|
|
417
|
+
return nil
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
func validQueryText(value string) bool {
|
|
421
|
+
for _, character := range value {
|
|
422
|
+
if character < 0x20 || character == 0x7f {
|
|
423
|
+
return false
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
return true
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
func containsStatus(statuses []int, status int) bool {
|
|
430
|
+
for _, candidate := range statuses {
|
|
431
|
+
if candidate == status {
|
|
432
|
+
return true
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return false
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
func requireJSONContentType(headers http.Header) error {
|
|
439
|
+
mediaType, _, err := mime.ParseMediaType(headers.Get("Content-Type"))
|
|
440
|
+
if err != nil || mediaType != "application/json" {
|
|
441
|
+
return fmt.Errorf("response media type is not application/json")
|
|
442
|
+
}
|
|
443
|
+
return nil
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
func requireResponseHeaders(headers http.Header, required map[string]string) error {
|
|
447
|
+
for name, expected := range required {
|
|
448
|
+
if strings.TrimSpace(name) == "" || expected == "" || headers.Get(name) != expected {
|
|
449
|
+
return fmt.Errorf("required response header is missing or invalid")
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return nil
|
|
453
|
+
}
|