Skip to content

Commit 5a283b7

Browse files
waveywavesclaude
andcommitted
feat(validators): lazy-initialize schema validators with sync.Once
Replace the eager init() function that compiles all JSON schema validators at package load time with lazy initialization using sync.Once. Each schema family (CycloneDX, CSAF, RunnerContext, PRInfo, AIAgentConfig, AICodingSession) now has its own sync.Once guard and private initializer that creates a dedicated jsonschema.Compiler with only the resources needed for that family. This avoids paying the compilation cost for all schemas upfront when only a subset may be needed, and removes the package-level schemaURLMapping map that was only used by init(). Fixes #919 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Vibhav Bobade <vibhav.bobde@gmail.com>
1 parent b200376 commit 5a283b7

6 files changed

Lines changed: 245 additions & 48 deletions

File tree

internal/schemavalidators/schemavalidators.go

Lines changed: 122 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,10 @@ package schemavalidators
1818
import (
1919
_ "embed"
2020
"errors"
21+
"fmt"
2122
"slices"
2223
"strings"
24+
"sync"
2325

2426
"github.com/hashicorp/go-multierror"
2527
"github.com/santhosh-tekuri/jsonschema/v5"
@@ -119,69 +121,131 @@ var (
119121
aiCodingSessionSpecVersion0_1 string
120122
)
121123

122-
// schemaURLMapping maps the schema URL to the schema content. This is used to compile the schema validators
123-
// against the schemas on external_schemas/*. This is done in the init function.
124-
// The keys are the URLs of the schemas and the values are the schema content that can be found in the embedded
125-
// files.
126-
var schemaURLMapping = map[string]string{
127-
"http://cyclonedx.org/schema/jsf-0.82.schema.json": jsfSpecVersion0_82,
128-
"http://cyclonedx.org/schema/spdx.schema.json": spdxSpec,
129-
"http://cyclonedx.org/schema/bom-1.5.schema.json": bomSpecVersion1_5,
130-
"http://cyclonedx.org/schema/bom-1.6.schema.json": bomSpecVersion1_6,
131-
"https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json": casfSpecVersion2_0,
132-
"https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json": casfSpecVersion2_1,
133-
"https://www.first.org/cvss/cvss-v2.0.json": cvssSpecVersion2_0,
134-
"https://www.first.org/cvss/cvss-v3.0.json": cvssSpecVersion3_0,
135-
"https://www.first.org/cvss/cvss-v3.1.json": cvssSpecVersion3_1,
136-
"https://www.first.org/cvss/cvss-v4.0.json": cvssSpecVersion4_0,
137-
"https://chainloop.dev/schemas/runner-context-response-0.1.schema.json": runnerContextSpecVersion0_1,
138-
"https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json": prInfoSpecVersion1_0,
139-
"https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json": prInfoSpecVersion1_1,
140-
"https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json": prInfoSpecVersion1_2,
141-
"https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json": prInfoSpecVersion1_3,
142-
"https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json": aiAgentConfigSpecVersion0_1,
143-
"https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json": aiCodingSessionSpecVersion0_1,
124+
var (
125+
compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema
126+
cycloneDxOnce sync.Once
127+
compiledCSAFSchemas map[CSAFVersion]*jsonschema.Schema
128+
csafOnce sync.Once
129+
compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema
130+
runnerContextOnce sync.Once
131+
compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema
132+
prInfoOnce sync.Once
133+
compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema
134+
aiAgentConfigOnce sync.Once
135+
compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema
136+
aiCodingSessionOnce sync.Once
137+
)
138+
139+
func initCycloneDxSchemas() {
140+
compiler := jsonschema.NewCompiler()
141+
if err := compiler.AddResource("http://cyclonedx.org/schema/jsf-0.82.schema.json", strings.NewReader(jsfSpecVersion0_82)); err != nil {
142+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "http://cyclonedx.org/schema/jsf-0.82.schema.json", err))
143+
}
144+
if err := compiler.AddResource("http://cyclonedx.org/schema/spdx.schema.json", strings.NewReader(spdxSpec)); err != nil {
145+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "http://cyclonedx.org/schema/spdx.schema.json", err))
146+
}
147+
if err := compiler.AddResource("http://cyclonedx.org/schema/bom-1.5.schema.json", strings.NewReader(bomSpecVersion1_5)); err != nil {
148+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "http://cyclonedx.org/schema/bom-1.5.schema.json", err))
149+
}
150+
if err := compiler.AddResource("http://cyclonedx.org/schema/bom-1.6.schema.json", strings.NewReader(bomSpecVersion1_6)); err != nil {
151+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "http://cyclonedx.org/schema/bom-1.6.schema.json", err))
152+
}
153+
154+
// MustCompile panics if the embedded schema is malformed. This is a build-time
155+
// invariant: the schemas are embedded at compile time and must always be valid.
156+
compiledCycloneDxSchemas = map[CycloneDXVersion]*jsonschema.Schema{
157+
CycloneDXVersion1_5: compiler.MustCompile("http://cyclonedx.org/schema/bom-1.5.schema.json"),
158+
CycloneDXVersion1_6: compiler.MustCompile("http://cyclonedx.org/schema/bom-1.6.schema.json"),
159+
}
144160
}
145161

146-
var compiledCycloneDxSchemas map[CycloneDXVersion]*jsonschema.Schema
147-
var compiledCSAFSchemas map[CSAFVersion]*jsonschema.Schema
148-
var compiledRunnerContextSchemas map[RunnerContextVersion]*jsonschema.Schema
149-
var compiledPRInfoSchemas map[PRInfoVersion]*jsonschema.Schema
150-
var compiledAIAgentConfigSchemas map[AIAgentConfigVersion]*jsonschema.Schema
151-
var compiledAICodingSessionSchemas map[AICodingSessionVersion]*jsonschema.Schema
162+
func initCSAFSchemas() {
163+
compiler := jsonschema.NewCompiler()
164+
if err := compiler.AddResource("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json", strings.NewReader(casfSpecVersion2_0)); err != nil {
165+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json", err))
166+
}
167+
if err := compiler.AddResource("https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json", strings.NewReader(casfSpecVersion2_1)); err != nil {
168+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json", err))
169+
}
170+
if err := compiler.AddResource("https://www.first.org/cvss/cvss-v2.0.json", strings.NewReader(cvssSpecVersion2_0)); err != nil {
171+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://www.first.org/cvss/cvss-v2.0.json", err))
172+
}
173+
if err := compiler.AddResource("https://www.first.org/cvss/cvss-v3.0.json", strings.NewReader(cvssSpecVersion3_0)); err != nil {
174+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://www.first.org/cvss/cvss-v3.0.json", err))
175+
}
176+
if err := compiler.AddResource("https://www.first.org/cvss/cvss-v3.1.json", strings.NewReader(cvssSpecVersion3_1)); err != nil {
177+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://www.first.org/cvss/cvss-v3.1.json", err))
178+
}
179+
if err := compiler.AddResource("https://www.first.org/cvss/cvss-v4.0.json", strings.NewReader(cvssSpecVersion4_0)); err != nil {
180+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://www.first.org/cvss/cvss-v4.0.json", err))
181+
}
182+
183+
compiledCSAFSchemas = map[CSAFVersion]*jsonschema.Schema{
184+
CSAFVersion2_0: compiler.MustCompile("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json"),
185+
CSAFVersion2_1: compiler.MustCompile("https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json"),
186+
}
187+
}
152188

153-
func init() {
189+
func initRunnerContextSchemas() {
154190
compiler := jsonschema.NewCompiler()
155-
for url, schema := range schemaURLMapping {
156-
_ = compiler.AddResource(url, strings.NewReader(schema))
191+
if err := compiler.AddResource("https://chainloop.dev/schemas/runner-context-response-0.1.schema.json", strings.NewReader(runnerContextSpecVersion0_1)); err != nil {
192+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://chainloop.dev/schemas/runner-context-response-0.1.schema.json", err))
157193
}
158194

159-
compiledCycloneDxSchemas = make(map[CycloneDXVersion]*jsonschema.Schema)
160-
compiledCycloneDxSchemas[CycloneDXVersion1_5] = compiler.MustCompile("http://cyclonedx.org/schema/bom-1.5.schema.json")
161-
compiledCycloneDxSchemas[CycloneDXVersion1_6] = compiler.MustCompile("http://cyclonedx.org/schema/bom-1.6.schema.json")
195+
compiledRunnerContextSchemas = map[RunnerContextVersion]*jsonschema.Schema{
196+
RunnerContextVersion0_1: compiler.MustCompile("https://chainloop.dev/schemas/runner-context-response-0.1.schema.json"),
197+
}
198+
}
162199

163-
compiledCSAFSchemas = make(map[CSAFVersion]*jsonschema.Schema)
164-
compiledCSAFSchemas[CSAFVersion2_0] = compiler.MustCompile("https://docs.oasis-open.org/csaf/csaf/v2.0/csaf_json_schema.json")
165-
compiledCSAFSchemas[CSAFVersion2_1] = compiler.MustCompile("https://docs.oasis-open.org/csaf/csaf/v2.1/csaf_json_schema.json")
200+
func initPRInfoSchemas() {
201+
compiler := jsonschema.NewCompiler()
202+
if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_0)); err != nil {
203+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json", err))
204+
}
205+
if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_1)); err != nil {
206+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json", err))
207+
}
208+
if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_2)); err != nil {
209+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json", err))
210+
}
211+
if err := compiler.AddResource("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", strings.NewReader(prInfoSpecVersion1_3)); err != nil {
212+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json", err))
213+
}
166214

167-
compiledRunnerContextSchemas = make(map[RunnerContextVersion]*jsonschema.Schema)
168-
compiledRunnerContextSchemas[RunnerContextVersion0_1] = compiler.MustCompile("https://chainloop.dev/schemas/runner-context-response-0.1.schema.json")
215+
compiledPRInfoSchemas = map[PRInfoVersion]*jsonschema.Schema{
216+
PRInfoVersion1_0: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json"),
217+
PRInfoVersion1_1: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json"),
218+
PRInfoVersion1_2: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json"),
219+
PRInfoVersion1_3: compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json"),
220+
}
221+
}
169222

170-
compiledPRInfoSchemas = make(map[PRInfoVersion]*jsonschema.Schema)
171-
compiledPRInfoSchemas[PRInfoVersion1_0] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.0/pr-info.schema.json")
172-
compiledPRInfoSchemas[PRInfoVersion1_1] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.1/pr-info.schema.json")
173-
compiledPRInfoSchemas[PRInfoVersion1_2] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.2/pr-info.schema.json")
174-
compiledPRInfoSchemas[PRInfoVersion1_3] = compiler.MustCompile("https://schemas.chainloop.dev/prinfo/1.3/pr-info.schema.json")
223+
func initAIAgentConfigSchemas() {
224+
compiler := jsonschema.NewCompiler()
225+
if err := compiler.AddResource("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", strings.NewReader(aiAgentConfigSpecVersion0_1)); err != nil {
226+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json", err))
227+
}
175228

176-
compiledAIAgentConfigSchemas = make(map[AIAgentConfigVersion]*jsonschema.Schema)
177-
compiledAIAgentConfigSchemas[AIAgentConfigVersion0_1] = compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json")
229+
compiledAIAgentConfigSchemas = map[AIAgentConfigVersion]*jsonschema.Schema{
230+
AIAgentConfigVersion0_1: compiler.MustCompile("https://schemas.chainloop.dev/aiagentconfig/0.1/ai-agent-config.schema.json"),
231+
}
232+
}
178233

179-
compiledAICodingSessionSchemas = make(map[AICodingSessionVersion]*jsonschema.Schema)
180-
compiledAICodingSessionSchemas[AICodingSessionVersion0_1] = compiler.MustCompile("https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json")
234+
func initAICodingSessionSchemas() {
235+
compiler := jsonschema.NewCompiler()
236+
if err := compiler.AddResource("https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", strings.NewReader(aiCodingSessionSpecVersion0_1)); err != nil {
237+
panic(fmt.Sprintf("schemavalidators: failed to add resource %s: %v", "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", err))
238+
}
239+
240+
compiledAICodingSessionSchemas = map[AICodingSessionVersion]*jsonschema.Schema{
241+
AICodingSessionVersion0_1: compiler.MustCompile("https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json"),
242+
}
181243
}
182244

183245
// ValidateCycloneDX validates the given object against the specified CycloneDX schema version.
184246
func ValidateCycloneDX(data interface{}, version CycloneDXVersion) error {
247+
cycloneDxOnce.Do(initCycloneDxSchemas)
248+
185249
if version == "" {
186250
version = CycloneDXVersion1_6
187251
}
@@ -222,6 +286,8 @@ func ValidateCycloneDX(data interface{}, version CycloneDXVersion) error {
222286
// ValidateCSAF validates the given object against a CSAF schema version.
223287
// The schema version is determined by the "csaf_version" field in the object.
224288
func ValidateCSAF(data interface{}) error {
289+
csafOnce.Do(initCSAFSchemas)
290+
225291
var errs error
226292
err := compiledCSAFSchemas[CSAFVersion2_1].Validate(data)
227293
if err != nil {
@@ -249,6 +315,8 @@ func ValidateCSAF(data interface{}) error {
249315
// ValidateChainloopRunnerContext validates the runner context schema.
250316
// The schema version is determined by the "id" field in the object.
251317
func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersion) error {
318+
runnerContextOnce.Do(initRunnerContextSchemas)
319+
252320
if version == "" {
253321
version = RunnerContextVersion0_1
254322
}
@@ -271,6 +339,8 @@ func ValidateChainloopRunnerContext(data interface{}, version RunnerContextVersi
271339

272340
// ValidatePRInfo validates the PR/MR info schema.
273341
func ValidatePRInfo(data interface{}, version PRInfoVersion) error {
342+
prInfoOnce.Do(initPRInfoSchemas)
343+
274344
if version == "" {
275345
version = PRInfoVersion1_3
276346
}
@@ -293,6 +363,8 @@ func ValidatePRInfo(data interface{}, version PRInfoVersion) error {
293363

294364
// ValidateAIAgentConfig validates the AI agent config schema.
295365
func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error {
366+
aiAgentConfigOnce.Do(initAIAgentConfigSchemas)
367+
296368
if version == "" {
297369
version = AIAgentConfigVersion0_1
298370
}
@@ -315,6 +387,8 @@ func ValidateAIAgentConfig(data any, version AIAgentConfigVersion) error {
315387

316388
// ValidateAICodingSession validates the AI coding session schema.
317389
func ValidateAICodingSession(data any, version AICodingSessionVersion) error {
390+
aiCodingSessionOnce.Do(initAICodingSessionSchemas)
391+
318392
if version == "" {
319393
version = AICodingSessionVersion0_1
320394
}

internal/schemavalidators/schemavalidators_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,88 @@ func TestValidateRunnerContext(t *testing.T) {
233233
}
234234
}
235235

236+
func TestValidatePRInfo(t *testing.T) {
237+
testCases := []struct {
238+
name string
239+
filePath string
240+
wantErr string
241+
}{
242+
{
243+
name: "valid PR info with all fields",
244+
filePath: "./testdata/pr_info_valid.json",
245+
},
246+
{
247+
name: "missing required fields",
248+
filePath: "./testdata/pr_info_missing_required.json",
249+
wantErr: "missing properties",
250+
},
251+
{
252+
name: "completely wrong format",
253+
filePath: "./testdata/sbom-spdx.json",
254+
wantErr: "missing properties",
255+
},
256+
}
257+
258+
for _, tc := range testCases {
259+
t.Run(tc.name, func(t *testing.T) {
260+
f, err := os.ReadFile(tc.filePath)
261+
require.NoError(t, err)
262+
263+
var v any
264+
require.NoError(t, json.Unmarshal(f, &v))
265+
266+
err = schemavalidators.ValidatePRInfo(v, "")
267+
if tc.wantErr != "" {
268+
require.ErrorContains(t, err, tc.wantErr)
269+
return
270+
}
271+
272+
require.NoError(t, err)
273+
})
274+
}
275+
}
276+
277+
func TestValidateAICodingSession(t *testing.T) {
278+
testCases := []struct {
279+
name string
280+
filePath string
281+
wantErr string
282+
}{
283+
{
284+
name: "valid coding session",
285+
filePath: "./testdata/ai_coding_session_valid.json",
286+
},
287+
{
288+
name: "missing required fields",
289+
filePath: "./testdata/ai_coding_session_missing_required.json",
290+
wantErr: "missing properties",
291+
},
292+
{
293+
name: "completely wrong format",
294+
filePath: "./testdata/sbom-spdx.json",
295+
wantErr: "missing properties",
296+
},
297+
}
298+
299+
for _, tc := range testCases {
300+
t.Run(tc.name, func(t *testing.T) {
301+
f, err := os.ReadFile(tc.filePath)
302+
require.NoError(t, err)
303+
304+
var v any
305+
require.NoError(t, json.Unmarshal(f, &v))
306+
307+
err = schemavalidators.ValidateAICodingSession(v, "")
308+
if tc.wantErr != "" {
309+
require.ErrorContains(t, err, tc.wantErr)
310+
return
311+
}
312+
313+
require.NoError(t, err)
314+
})
315+
}
316+
}
317+
236318
func TestValidateAIAgentConfig(t *testing.T) {
237319
testCases := []struct {
238320
name string
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"schema_version": "0.1"
3+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"schema_version": "0.1",
3+
"agent": {
4+
"name": "claude-code",
5+
"version": "1.0.0"
6+
},
7+
"session": {
8+
"id": "sess-abc123",
9+
"slug": "fix-bug",
10+
"started_at": "2026-01-15T10:00:00Z",
11+
"ended_at": "2026-01-15T10:30:00Z",
12+
"duration_seconds": 1800
13+
}
14+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"title": "Missing platform, type, number, and url"
3+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
{
2+
"platform": "github",
3+
"type": "pull_request",
4+
"number": "42",
5+
"title": "Add schema validators",
6+
"url": "https://github.com/chainloop-dev/chainloop/pull/42",
7+
"source_branch": "feat/schema-validators",
8+
"target_branch": "main",
9+
"author": {
10+
"login": "octocat",
11+
"type": "User"
12+
},
13+
"reviewers": [
14+
{
15+
"login": "reviewer1",
16+
"type": "User",
17+
"requested": true,
18+
"review_status": "APPROVED"
19+
}
20+
]
21+
}

0 commit comments

Comments
 (0)