Skip to content

Commit 3bb028c

Browse files
realaravinthLoïc Dachary
and
Loïc Dachary
authored
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>
1 parent 49dd906 commit 3bb028c

26 files changed

+577
-74
lines changed

.gitignore

+2
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ _testmain.go
3636
coverage.all
3737
cpu.out
3838

39+
/modules/migration/bindata.go
40+
/modules/migration/bindata.go.hash
3941
/modules/options/bindata.go
4042
/modules/options/bindata.go.hash
4143
/modules/public/bindata.go

cmd/restore_repo.go

+5
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,10 @@ var CmdRestoreRepository = cli.Command{
4343
Usage: `Which items will be restored, one or more units should be separated as comma.
4444
wiki, issues, labels, releases, release_assets, milestones, pull_requests, comments are allowed. Empty means all units.`,
4545
},
46+
cli.BoolFlag{
47+
Name: "validation",
48+
Usage: "Sanity check the content of the files before trying to load them",
49+
},
4650
},
4751
}
4852

@@ -58,6 +62,7 @@ func runRestoreRepository(c *cli.Context) error {
5862
c.String("owner_name"),
5963
c.String("repo_name"),
6064
c.StringSlice("units"),
65+
c.Bool("validation"),
6166
)
6267
if statusCode == http.StatusOK {
6368
return nil

go.mod

+1
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,7 @@ require (
9797
github.com/quasoft/websspi v1.0.0
9898
github.com/rs/xid v1.3.0 // indirect
9999
github.com/russross/blackfriday/v2 v2.1.0 // indirect
100+
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect
100101
github.com/sergi/go-diff v1.2.0
101102
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
102103
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546

go.sum

+2
Original file line numberDiff line numberDiff line change
@@ -1039,6 +1039,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
10391039
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
10401040
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
10411041
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
1042+
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE=
1043+
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
10421044
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
10431045
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
10441046
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=

integrations/dump_restore_test.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ func TestDumpRestore(t *testing.T) {
8181
//
8282

8383
newreponame := "restoredrepo"
84-
err = migrations.RestoreRepository(ctx, d, repo.OwnerName, newreponame, []string{"labels", "milestones", "issues", "comments"})
84+
err = migrations.RestoreRepository(ctx, d, repo.OwnerName, newreponame, []string{"labels", "milestones", "issues", "comments"}, false)
8585
assert.NoError(t, err)
8686

8787
newrepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: newreponame}).(*repo_model.Repository)

modules/migration/file_format.go

+112
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migration
6+
7+
import (
8+
"fmt"
9+
"os"
10+
"strings"
11+
12+
"code.gitea.io/gitea/modules/json"
13+
"code.gitea.io/gitea/modules/log"
14+
15+
"github.com/santhosh-tekuri/jsonschema/v5"
16+
"gopkg.in/yaml.v2"
17+
)
18+
19+
// Load project data from file, with optional validation
20+
func Load(filename string, data interface{}, validation bool) error {
21+
isJSON := strings.HasSuffix(filename, ".json")
22+
23+
bs, err := os.ReadFile(filename)
24+
if err != nil {
25+
return err
26+
}
27+
28+
if validation {
29+
err := validate(bs, data, isJSON)
30+
if err != nil {
31+
return err
32+
}
33+
}
34+
return unmarshal(bs, data, isJSON)
35+
}
36+
37+
func unmarshal(bs []byte, data interface{}, isJSON bool) error {
38+
if isJSON {
39+
return json.Unmarshal(bs, data)
40+
}
41+
return yaml.Unmarshal(bs, data)
42+
}
43+
44+
func getSchema(filename string) (*jsonschema.Schema, error) {
45+
c := jsonschema.NewCompiler()
46+
c.LoadURL = openSchema
47+
return c.Compile(filename)
48+
}
49+
50+
func validate(bs []byte, datatype interface{}, isJSON bool) error {
51+
var v interface{}
52+
err := unmarshal(bs, &v, isJSON)
53+
if err != nil {
54+
return err
55+
}
56+
if !isJSON {
57+
v, err = toStringKeys(v)
58+
if err != nil {
59+
return err
60+
}
61+
}
62+
63+
var schemaFilename string
64+
switch datatype := datatype.(type) {
65+
case *[]*Issue:
66+
schemaFilename = "issue.json"
67+
case *[]*Milestone:
68+
schemaFilename = "milestone.json"
69+
default:
70+
return fmt.Errorf("file_format:validate: %T has not a validation implemented", datatype)
71+
}
72+
73+
sch, err := getSchema(schemaFilename)
74+
if err != nil {
75+
return err
76+
}
77+
err = sch.Validate(v)
78+
if err != nil {
79+
log.Error("migration validation with %s failed for\n%s", schemaFilename, string(bs))
80+
}
81+
return err
82+
}
83+
84+
func toStringKeys(val interface{}) (interface{}, error) {
85+
var err error
86+
switch val := val.(type) {
87+
case map[interface{}]interface{}:
88+
m := make(map[string]interface{})
89+
for k, v := range val {
90+
k, ok := k.(string)
91+
if !ok {
92+
return nil, fmt.Errorf("found non-string key %T %s", k, k)
93+
}
94+
m[k], err = toStringKeys(v)
95+
if err != nil {
96+
return nil, err
97+
}
98+
}
99+
return m, nil
100+
case []interface{}:
101+
l := make([]interface{}, len(val))
102+
for i, v := range val {
103+
l[i], err = toStringKeys(v)
104+
if err != nil {
105+
return nil, err
106+
}
107+
}
108+
return l, nil
109+
default:
110+
return val, nil
111+
}
112+
}

modules/migration/file_format_test.go

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// Copyright 2022 The Gitea Authors. All rights reserved.
2+
// Use of this source code is governed by a MIT-style
3+
// license that can be found in the LICENSE file.
4+
5+
package migration
6+
7+
import (
8+
"strings"
9+
"testing"
10+
11+
"github.com/santhosh-tekuri/jsonschema/v5"
12+
"github.com/stretchr/testify/assert"
13+
)
14+
15+
func TestMigrationJSON_IssueOK(t *testing.T) {
16+
issues := make([]*Issue, 0, 10)
17+
err := Load("file_format_testdata/issue_a.json", &issues, true)
18+
assert.NoError(t, err)
19+
err = Load("file_format_testdata/issue_a.yml", &issues, true)
20+
assert.NoError(t, err)
21+
}
22+
23+
func TestMigrationJSON_IssueFail(t *testing.T) {
24+
issues := make([]*Issue, 0, 10)
25+
err := Load("file_format_testdata/issue_b.json", &issues, true)
26+
if _, ok := err.(*jsonschema.ValidationError); ok {
27+
errors := strings.Split(err.(*jsonschema.ValidationError).GoString(), "\n")
28+
assert.Contains(t, errors[1], "missing properties")
29+
assert.Contains(t, errors[1], "poster_id")
30+
} else {
31+
t.Fatalf("got: type %T with value %s, want: *jsonschema.ValidationError", err, err)
32+
}
33+
}
34+
35+
func TestMigrationJSON_MilestoneOK(t *testing.T) {
36+
milestones := make([]*Milestone, 0, 10)
37+
err := Load("file_format_testdata/milestones.json", &milestones, true)
38+
assert.NoError(t, err)
39+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
[
2+
{
3+
"number": 1,
4+
"poster_id": 1,
5+
"poster_name": "name_a",
6+
"title": "title_a",
7+
"content": "content_a",
8+
"state": "closed",
9+
"is_locked": false,
10+
"created": "1985-04-12T23:20:50.52Z",
11+
"updated": "1986-04-12T23:20:50.52Z",
12+
"closed": "1987-04-12T23:20:50.52Z"
13+
}
14+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
- number: 1
2+
poster_id: 1
3+
poster_name: name_a
4+
title: title_a
5+
content: content_a
6+
state: closed
7+
is_locked: false
8+
created: 2021-05-27T15:24:13+02:00
9+
updated: 2021-11-11T10:52:45+01:00
10+
closed: 2021-11-11T10:52:45+01:00
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
[
2+
{
3+
"number": 1
4+
}
5+
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
[
2+
{
3+
"title": "title_a",
4+
"description": "description_a",
5+
"deadline": "1988-04-12T23:20:50.52Z",
6+
"created": "1985-04-12T23:20:50.52Z",
7+
"updated": "1986-04-12T23:20:50.52Z",
8+
"closed": "1987-04-12T23:20:50.52Z",
9+
"state": "closed"
10+
},
11+
{
12+
"title": "title_b",
13+
"description": "description_b",
14+
"deadline": "1998-04-12T23:20:50.52Z",
15+
"created": "1995-04-12T23:20:50.52Z",
16+
"updated": "1996-04-12T23:20:50.52Z",
17+
"closed": null,
18+
"state": "open"
19+
}
20+
]

modules/migration/issue.go

+16-16
Original file line numberDiff line numberDiff line change
@@ -28,21 +28,21 @@ func (c BasicIssueContext) ForeignID() int64 {
2828

2929
// Issue is a standard issue information
3030
type Issue struct {
31-
Number int64
32-
PosterID int64 `yaml:"poster_id"`
33-
PosterName string `yaml:"poster_name"`
34-
PosterEmail string `yaml:"poster_email"`
35-
Title string
36-
Content string
37-
Ref string
38-
Milestone string
39-
State string // closed, open
40-
IsLocked bool `yaml:"is_locked"`
41-
Created time.Time
42-
Updated time.Time
43-
Closed *time.Time
44-
Labels []*Label
45-
Reactions []*Reaction
46-
Assignees []string
31+
Number int64 `json:"number"`
32+
PosterID int64 `yaml:"poster_id" json:"poster_id"`
33+
PosterName string `yaml:"poster_name" json:"poster_name"`
34+
PosterEmail string `yaml:"poster_email" json:"poster_email"`
35+
Title string `json:"title"`
36+
Content string `json:"content"`
37+
Ref string `json:"ref"`
38+
Milestone string `json:"milestone"`
39+
State string `json:"state"` // closed, open
40+
IsLocked bool `yaml:"is_locked" json:"is_locked"`
41+
Created time.Time `json:"created"`
42+
Updated time.Time `json:"updated"`
43+
Closed *time.Time `json:"closed"`
44+
Labels []*Label `json:"labels"`
45+
Reactions []*Reaction `json:"reactions"`
46+
Assignees []string `json:"assignees"`
4747
Context IssueContext `yaml:"-"`
4848
}

modules/migration/label.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ package migration
77

88
// Label defines a standard label information
99
type Label struct {
10-
Name string
11-
Color string
12-
Description string
10+
Name string `json:"name"`
11+
Color string `json:"color"`
12+
Description string `json:"description"`
1313
}

modules/migration/milestone.go

+7-7
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@ import "time"
99

1010
// Milestone defines a standard milestone
1111
type Milestone struct {
12-
Title string
13-
Description string
14-
Deadline *time.Time
15-
Created time.Time
16-
Updated *time.Time
17-
Closed *time.Time
18-
State string // open, closed
12+
Title string `json:"title"`
13+
Description string `json:"description"`
14+
Deadline *time.Time `json:"deadline"`
15+
Created time.Time `json:"created"`
16+
Updated *time.Time `json:"updated"`
17+
Closed *time.Time `json:"closed"`
18+
State string `json:"state"` // open, closed
1919
}

modules/migration/reaction.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ package migration
66

77
// Reaction represents a reaction to an issue/pr/comment.
88
type Reaction struct {
9-
UserID int64 `yaml:"user_id"`
10-
UserName string `yaml:"user_name"`
11-
Content string
9+
UserID int64 `yaml:"user_id" json:"user_id"`
10+
UserName string `yaml:"user_name" json:"user_name"`
11+
Content string `json:"content"`
1212
}

0 commit comments

Comments
 (0)