Skip to content

Commit

Permalink
Validate migration files (go-gitea#18203)
Browse files Browse the repository at this point in the history
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>
  • Loading branch information
2 people authored and Stelios Malathouras committed Mar 28, 2022
1 parent c33b7d7 commit 4359287
Show file tree
Hide file tree
Showing 26 changed files with 577 additions and 74 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ _testmain.go
coverage.all
cpu.out

/modules/migration/bindata.go
/modules/migration/bindata.go.hash
/modules/options/bindata.go
/modules/options/bindata.go.hash
/modules/public/bindata.go
Expand Down
5 changes: 5 additions & 0 deletions cmd/restore_repo.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ var CmdRestoreRepository = cli.Command{
Usage: `Which items will be restored, one or more units should be separated as comma.
wiki, issues, labels, releases, release_assets, milestones, pull_requests, comments are allowed. Empty means all units.`,
},
cli.BoolFlag{
Name: "validation",
Usage: "Sanity check the content of the files before trying to load them",
},
},
}

Expand All @@ -58,6 +62,7 @@ func runRestoreRepository(c *cli.Context) error {
c.String("owner_name"),
c.String("repo_name"),
c.StringSlice("units"),
c.Bool("validation"),
)
if statusCode == http.StatusOK {
return nil
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ require (
github.com/quasoft/websspi v1.0.0
github.com/rs/xid v1.3.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 // indirect
github.com/sergi/go-diff v1.2.0
github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect
github.com/shurcooL/vfsgen v0.0.0-20200824052919-0d455de96546
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -1039,6 +1039,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/samuel/go-zookeeper v0.0.0-20190923202752-2cc03de413da/go.mod h1:gi+0XIa01GRL2eRQVjQkKGqKF3SF9vZR/HnPullcV2E=
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0 h1:TToq11gyfNlrMFZiYujSekIsPd9AmsA2Bj/iv+s4JHE=
github.com/santhosh-tekuri/jsonschema/v5 v5.0.0/go.mod h1:FKdcjfQW6rpZSnxxUvEA5H/cDPdvJ/SZJQLWWXWGrZ0=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM=
Expand Down
2 changes: 1 addition & 1 deletion integrations/dump_restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ func TestDumpRestore(t *testing.T) {
//

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

newrepo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{Name: newreponame}).(*repo_model.Repository)
Expand Down
112 changes: 112 additions & 0 deletions modules/migration/file_format.go
Original file line number Diff line number Diff line change
@@ -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
}
}
39 changes: 39 additions & 0 deletions modules/migration/file_format_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
14 changes: 14 additions & 0 deletions modules/migration/file_format_testdata/issue_a.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
10 changes: 10 additions & 0 deletions modules/migration/file_format_testdata/issue_a.yml
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions modules/migration/file_format_testdata/issue_b.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
{
"number": 1
}
]
20 changes: 20 additions & 0 deletions modules/migration/file_format_testdata/milestones.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
32 changes: 16 additions & 16 deletions modules/migration/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,21 @@ func (c BasicIssueContext) ForeignID() int64 {

// Issue is a standard issue information
type Issue struct {
Number int64
PosterID int64 `yaml:"poster_id"`
PosterName string `yaml:"poster_name"`
PosterEmail string `yaml:"poster_email"`
Title string
Content string
Ref string
Milestone string
State string // closed, open
IsLocked bool `yaml:"is_locked"`
Created time.Time
Updated time.Time
Closed *time.Time
Labels []*Label
Reactions []*Reaction
Assignees []string
Number int64 `json:"number"`
PosterID int64 `yaml:"poster_id" json:"poster_id"`
PosterName string `yaml:"poster_name" json:"poster_name"`
PosterEmail string `yaml:"poster_email" json:"poster_email"`
Title string `json:"title"`
Content string `json:"content"`
Ref string `json:"ref"`
Milestone string `json:"milestone"`
State string `json:"state"` // closed, open
IsLocked bool `yaml:"is_locked" json:"is_locked"`
Created time.Time `json:"created"`
Updated time.Time `json:"updated"`
Closed *time.Time `json:"closed"`
Labels []*Label `json:"labels"`
Reactions []*Reaction `json:"reactions"`
Assignees []string `json:"assignees"`
Context IssueContext `yaml:"-"`
}
6 changes: 3 additions & 3 deletions modules/migration/label.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package migration

// Label defines a standard label information
type Label struct {
Name string
Color string
Description string
Name string `json:"name"`
Color string `json:"color"`
Description string `json:"description"`
}
14 changes: 7 additions & 7 deletions modules/migration/milestone.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import "time"

// Milestone defines a standard milestone
type Milestone struct {
Title string
Description string
Deadline *time.Time
Created time.Time
Updated *time.Time
Closed *time.Time
State string // open, closed
Title string `json:"title"`
Description string `json:"description"`
Deadline *time.Time `json:"deadline"`
Created time.Time `json:"created"`
Updated *time.Time `json:"updated"`
Closed *time.Time `json:"closed"`
State string `json:"state"` // open, closed
}
6 changes: 3 additions & 3 deletions modules/migration/reaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package migration

// Reaction represents a reaction to an issue/pr/comment.
type Reaction struct {
UserID int64 `yaml:"user_id"`
UserName string `yaml:"user_name"`
Content string
UserID int64 `yaml:"user_id" json:"user_id"`
UserName string `yaml:"user_name" json:"user_name"`
Content string `json:"content"`
}
Loading

0 comments on commit 4359287

Please sign in to comment.