mirror of https://github.com/go-gitea/gitea
Validate migration files (#18203)
JSON Schema validation for data used by Gitea during migrations Discussion at https://forum.forgefriends.org/t/common-json-schema-for-repository-information/563 Co-authored-by: Loïc Dachary <loic@dachary.org>pull/18414/head
parent
49dd906753
commit
3bb028cc46
@ -0,0 +1,112 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package migration |
||||||
|
|
||||||
|
import ( |
||||||
|
"fmt" |
||||||
|
"os" |
||||||
|
"strings" |
||||||
|
|
||||||
|
"code.gitea.io/gitea/modules/json" |
||||||
|
"code.gitea.io/gitea/modules/log" |
||||||
|
|
||||||
|
"github.com/santhosh-tekuri/jsonschema/v5" |
||||||
|
"gopkg.in/yaml.v2" |
||||||
|
) |
||||||
|
|
||||||
|
// Load project data from file, with optional validation
|
||||||
|
func Load(filename string, data interface{}, validation bool) error { |
||||||
|
isJSON := strings.HasSuffix(filename, ".json") |
||||||
|
|
||||||
|
bs, err := os.ReadFile(filename) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
if validation { |
||||||
|
err := validate(bs, data, isJSON) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
return unmarshal(bs, data, isJSON) |
||||||
|
} |
||||||
|
|
||||||
|
func unmarshal(bs []byte, data interface{}, isJSON bool) error { |
||||||
|
if isJSON { |
||||||
|
return json.Unmarshal(bs, data) |
||||||
|
} |
||||||
|
return yaml.Unmarshal(bs, data) |
||||||
|
} |
||||||
|
|
||||||
|
func getSchema(filename string) (*jsonschema.Schema, error) { |
||||||
|
c := jsonschema.NewCompiler() |
||||||
|
c.LoadURL = openSchema |
||||||
|
return c.Compile(filename) |
||||||
|
} |
||||||
|
|
||||||
|
func validate(bs []byte, datatype interface{}, isJSON bool) error { |
||||||
|
var v interface{} |
||||||
|
err := unmarshal(bs, &v, isJSON) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if !isJSON { |
||||||
|
v, err = toStringKeys(v) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
var schemaFilename string |
||||||
|
switch datatype := datatype.(type) { |
||||||
|
case *[]*Issue: |
||||||
|
schemaFilename = "issue.json" |
||||||
|
case *[]*Milestone: |
||||||
|
schemaFilename = "milestone.json" |
||||||
|
default: |
||||||
|
return fmt.Errorf("file_format:validate: %T has not a validation implemented", datatype) |
||||||
|
} |
||||||
|
|
||||||
|
sch, err := getSchema(schemaFilename) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
err = sch.Validate(v) |
||||||
|
if err != nil { |
||||||
|
log.Error("migration validation with %s failed for\n%s", schemaFilename, string(bs)) |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
func toStringKeys(val interface{}) (interface{}, error) { |
||||||
|
var err error |
||||||
|
switch val := val.(type) { |
||||||
|
case map[interface{}]interface{}: |
||||||
|
m := make(map[string]interface{}) |
||||||
|
for k, v := range val { |
||||||
|
k, ok := k.(string) |
||||||
|
if !ok { |
||||||
|
return nil, fmt.Errorf("found non-string key %T %s", k, k) |
||||||
|
} |
||||||
|
m[k], err = toStringKeys(v) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
} |
||||||
|
return m, nil |
||||||
|
case []interface{}: |
||||||
|
l := make([]interface{}, len(val)) |
||||||
|
for i, v := range val { |
||||||
|
l[i], err = toStringKeys(v) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
} |
||||||
|
return l, nil |
||||||
|
default: |
||||||
|
return val, nil |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,39 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
package migration |
||||||
|
|
||||||
|
import ( |
||||||
|
"strings" |
||||||
|
"testing" |
||||||
|
|
||||||
|
"github.com/santhosh-tekuri/jsonschema/v5" |
||||||
|
"github.com/stretchr/testify/assert" |
||||||
|
) |
||||||
|
|
||||||
|
func TestMigrationJSON_IssueOK(t *testing.T) { |
||||||
|
issues := make([]*Issue, 0, 10) |
||||||
|
err := Load("file_format_testdata/issue_a.json", &issues, true) |
||||||
|
assert.NoError(t, err) |
||||||
|
err = Load("file_format_testdata/issue_a.yml", &issues, true) |
||||||
|
assert.NoError(t, err) |
||||||
|
} |
||||||
|
|
||||||
|
func TestMigrationJSON_IssueFail(t *testing.T) { |
||||||
|
issues := make([]*Issue, 0, 10) |
||||||
|
err := Load("file_format_testdata/issue_b.json", &issues, true) |
||||||
|
if _, ok := err.(*jsonschema.ValidationError); ok { |
||||||
|
errors := strings.Split(err.(*jsonschema.ValidationError).GoString(), "\n") |
||||||
|
assert.Contains(t, errors[1], "missing properties") |
||||||
|
assert.Contains(t, errors[1], "poster_id") |
||||||
|
} else { |
||||||
|
t.Fatalf("got: type %T with value %s, want: *jsonschema.ValidationError", err, err) |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
func TestMigrationJSON_MilestoneOK(t *testing.T) { |
||||||
|
milestones := make([]*Milestone, 0, 10) |
||||||
|
err := Load("file_format_testdata/milestones.json", &milestones, true) |
||||||
|
assert.NoError(t, err) |
||||||
|
} |
@ -0,0 +1,14 @@ |
|||||||
|
[ |
||||||
|
{ |
||||||
|
"number": 1, |
||||||
|
"poster_id": 1, |
||||||
|
"poster_name": "name_a", |
||||||
|
"title": "title_a", |
||||||
|
"content": "content_a", |
||||||
|
"state": "closed", |
||||||
|
"is_locked": false, |
||||||
|
"created": "1985-04-12T23:20:50.52Z", |
||||||
|
"updated": "1986-04-12T23:20:50.52Z", |
||||||
|
"closed": "1987-04-12T23:20:50.52Z" |
||||||
|
} |
||||||
|
] |
@ -0,0 +1,10 @@ |
|||||||
|
- number: 1 |
||||||
|
poster_id: 1 |
||||||
|
poster_name: name_a |
||||||
|
title: title_a |
||||||
|
content: content_a |
||||||
|
state: closed |
||||||
|
is_locked: false |
||||||
|
created: 2021-05-27T15:24:13+02:00 |
||||||
|
updated: 2021-11-11T10:52:45+01:00 |
||||||
|
closed: 2021-11-11T10:52:45+01:00 |
@ -0,0 +1,5 @@ |
|||||||
|
[ |
||||||
|
{ |
||||||
|
"number": 1 |
||||||
|
} |
||||||
|
] |
@ -0,0 +1,20 @@ |
|||||||
|
[ |
||||||
|
{ |
||||||
|
"title": "title_a", |
||||||
|
"description": "description_a", |
||||||
|
"deadline": "1988-04-12T23:20:50.52Z", |
||||||
|
"created": "1985-04-12T23:20:50.52Z", |
||||||
|
"updated": "1986-04-12T23:20:50.52Z", |
||||||
|
"closed": "1987-04-12T23:20:50.52Z", |
||||||
|
"state": "closed" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"title": "title_b", |
||||||
|
"description": "description_b", |
||||||
|
"deadline": "1998-04-12T23:20:50.52Z", |
||||||
|
"created": "1995-04-12T23:20:50.52Z", |
||||||
|
"updated": "1996-04-12T23:20:50.52Z", |
||||||
|
"closed": null, |
||||||
|
"state": "open" |
||||||
|
} |
||||||
|
] |
@ -0,0 +1,114 @@ |
|||||||
|
{ |
||||||
|
"title": "Issue", |
||||||
|
"description": "Issues associated to a repository within a forge (Gitea, GitLab, etc.).", |
||||||
|
|
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"type": "object", |
||||||
|
"additionalProperties": false, |
||||||
|
"properties": { |
||||||
|
"number": { |
||||||
|
"description": "Unique identifier, relative to the repository.", |
||||||
|
"type": "number" |
||||||
|
}, |
||||||
|
"poster_id": { |
||||||
|
"description": "Unique identifier of the user who authored the issue.", |
||||||
|
"type": "number" |
||||||
|
}, |
||||||
|
"poster_name": { |
||||||
|
"description": "Name of the user who authored the issue.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"poster_email": { |
||||||
|
"description": "Email of the user who authored the issue.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"title": { |
||||||
|
"description": "Short description displayed as the title.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"content": { |
||||||
|
"description": "Long, multiline, description.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"ref": { |
||||||
|
"description": "Target branch in the repository.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"milestone": { |
||||||
|
"description": "Name of the milestone.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"state": { |
||||||
|
"description": "A 'closed' issue will not see any activity in the future, otherwise it is 'open'.", |
||||||
|
"enum": [ |
||||||
|
"closed", |
||||||
|
"open" |
||||||
|
] |
||||||
|
}, |
||||||
|
"is_locked": { |
||||||
|
"description": "A locked issue can only be modified by privileged users.", |
||||||
|
"type": "boolean" |
||||||
|
}, |
||||||
|
"created": { |
||||||
|
"description": "Creation time.", |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
"updated": { |
||||||
|
"description": "Last update time.", |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
"closed": { |
||||||
|
"description": "The last time 'state' changed to 'closed'.", |
||||||
|
"anyOf": [ |
||||||
|
{ |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"type": "null" |
||||||
|
} |
||||||
|
] |
||||||
|
}, |
||||||
|
"labels": { |
||||||
|
"description": "List of labels.", |
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"$ref": "label.json" |
||||||
|
} |
||||||
|
}, |
||||||
|
"reactions": { |
||||||
|
"description": "List of reactions.", |
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"$ref": "reaction.json" |
||||||
|
} |
||||||
|
}, |
||||||
|
"assignees": { |
||||||
|
"description": "List of assignees.", |
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"description": "Name of a user assigned to the issue.", |
||||||
|
"type": "string" |
||||||
|
} |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": [ |
||||||
|
"number", |
||||||
|
"poster_id", |
||||||
|
"poster_name", |
||||||
|
"title", |
||||||
|
"content", |
||||||
|
"state", |
||||||
|
"is_locked", |
||||||
|
"created", |
||||||
|
"updated" |
||||||
|
] |
||||||
|
}, |
||||||
|
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#", |
||||||
|
"$id": "http://example.com/issue.json", |
||||||
|
"$$target": "issue.json" |
||||||
|
} |
@ -0,0 +1,28 @@ |
|||||||
|
{ |
||||||
|
"title": "Label", |
||||||
|
"description": "Label associated to an issue.", |
||||||
|
|
||||||
|
"type": "object", |
||||||
|
"additionalProperties": false, |
||||||
|
"properties": { |
||||||
|
"name": { |
||||||
|
"description": "Name of the label, unique within the repository.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"color": { |
||||||
|
"description": "Color code of the label.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"description": { |
||||||
|
"description": "Long, multiline, description.", |
||||||
|
"type": "string" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": [ |
||||||
|
"name" |
||||||
|
], |
||||||
|
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#", |
||||||
|
"$id": "label.json", |
||||||
|
"$$target": "label.json" |
||||||
|
} |
@ -0,0 +1,67 @@ |
|||||||
|
{ |
||||||
|
"title": "Milestone", |
||||||
|
"description": "Milestone associated to a repository within a forge.", |
||||||
|
|
||||||
|
"type": "array", |
||||||
|
"items": { |
||||||
|
"type": "object", |
||||||
|
"additionalProperties": false, |
||||||
|
"properties": { |
||||||
|
"title": { |
||||||
|
"description": "Short description.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"description": { |
||||||
|
"description": "Long, multiline, description.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"deadline": { |
||||||
|
"description": "Deadline after which the milestone is overdue.", |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
"created": { |
||||||
|
"description": "Creation time.", |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
"updated": { |
||||||
|
"description": "Last update time.", |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
"closed": { |
||||||
|
"description": "The last time 'state' changed to 'closed'.", |
||||||
|
"anyOf": [ |
||||||
|
{ |
||||||
|
"type": "string", |
||||||
|
"format": "date-time" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"type": "null" |
||||||
|
} |
||||||
|
] |
||||||
|
}, |
||||||
|
"state": { |
||||||
|
"description": "A 'closed' issue will not see any activity in the future, otherwise it is 'open'.", |
||||||
|
"enum": [ |
||||||
|
"closed", |
||||||
|
"open" |
||||||
|
] |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": [ |
||||||
|
"title", |
||||||
|
"description", |
||||||
|
"deadline", |
||||||
|
"created", |
||||||
|
"updated", |
||||||
|
"closed", |
||||||
|
"state" |
||||||
|
] |
||||||
|
}, |
||||||
|
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#", |
||||||
|
"$id": "http://example.com/milestone.json", |
||||||
|
"$$target": "milestone.json" |
||||||
|
} |
@ -0,0 +1,29 @@ |
|||||||
|
{ |
||||||
|
"title": "Reaction", |
||||||
|
"description": "Reaction associated to an issue or a comment.", |
||||||
|
|
||||||
|
"type": "object", |
||||||
|
"additionalProperties": false, |
||||||
|
"properties": { |
||||||
|
"user_id": { |
||||||
|
"description": "Unique identifier of the user who authored the reaction.", |
||||||
|
"type": "number" |
||||||
|
}, |
||||||
|
"user_name": { |
||||||
|
"description": "Name of the user who authored the reaction.", |
||||||
|
"type": "string" |
||||||
|
}, |
||||||
|
"content": { |
||||||
|
"description": "Representation of the reaction", |
||||||
|
"type": "string" |
||||||
|
} |
||||||
|
}, |
||||||
|
"required": [ |
||||||
|
"user_id", |
||||||
|
"content" |
||||||
|
], |
||||||
|
|
||||||
|
"$schema": "http://json-schema.org/draft-04/schema#", |
||||||
|
"$id": "http://example.com/reaction.json", |
||||||
|
"$$target": "reaction.json" |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build bindata
|
||||||
|
// +build bindata
|
||||||
|
|
||||||
|
package migration |
||||||
|
|
||||||
|
//go:generate go run ../../build/generate-bindata.go ../../modules/migration/schemas migration bindata.go
|
@ -0,0 +1,40 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build !bindata
|
||||||
|
// +build !bindata
|
||||||
|
|
||||||
|
package migration |
||||||
|
|
||||||
|
import ( |
||||||
|
"io" |
||||||
|
"net/url" |
||||||
|
"os" |
||||||
|
"path" |
||||||
|
"path/filepath" |
||||||
|
) |
||||||
|
|
||||||
|
func openSchema(s string) (io.ReadCloser, error) { |
||||||
|
u, err := url.Parse(s) |
||||||
|
if err != nil { |
||||||
|
return nil, err |
||||||
|
} |
||||||
|
basename := path.Base(u.Path) |
||||||
|
filename := basename |
||||||
|
//
|
||||||
|
// Schema reference each other within the schemas directory but
|
||||||
|
// the tests run in the parent directory.
|
||||||
|
//
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) { |
||||||
|
filename = filepath.Join("schemas", basename) |
||||||
|
//
|
||||||
|
// Integration tests run from the git root directory, not the
|
||||||
|
// directory in which the test source is located.
|
||||||
|
//
|
||||||
|
if _, err := os.Stat(filename); os.IsNotExist(err) { |
||||||
|
filename = filepath.Join("modules/migration/schemas", basename) |
||||||
|
} |
||||||
|
} |
||||||
|
return os.Open(filename) |
||||||
|
} |
@ -0,0 +1,17 @@ |
|||||||
|
// Copyright 2022 The Gitea Authors. All rights reserved.
|
||||||
|
// Use of this source code is governed by a MIT-style
|
||||||
|
// license that can be found in the LICENSE file.
|
||||||
|
|
||||||
|
//go:build bindata
|
||||||
|
// +build bindata
|
||||||
|
|
||||||
|
package migration |
||||||
|
|
||||||
|
import ( |
||||||
|
"io" |
||||||
|
"path" |
||||||
|
) |
||||||
|
|
||||||
|
func openSchema(filename string) (io.ReadCloser, error) { |
||||||
|
return Assets.Open(path.Base(filename)) |
||||||
|
} |
Loading…
Reference in new issue