diff --git a/developers-italia.oas.yaml b/developers-italia.oas.yaml index 8b79e53..c7352f2 100644 --- a/developers-italia.oas.yaml +++ b/developers-italia.oas.yaml @@ -1387,12 +1387,20 @@ components: minLength: 1 maxLength: 99999 pattern: '.*' - urls: + url: + type: string + format: uri + minLength: 1 + maxLength: 255 + description: > + Repository URL this software is available at. + This is the current and canonical one. + aliases: type: array description: > - List of repository URLs this software is available at. - The first element of the array is the current and canonical one, while the others, if any, - are aliases to the current repository. + List of aliases for the current repository URL. + This is useful to keep track, for example, of previous addresses that redirect + to the current one. minItems: 1 maxItems: 255 items: @@ -1419,6 +1427,12 @@ components: example: '2022-06-07T14:56:23Z' description: The time the log was updated (RFC 3339 datetime) readOnly: true + required: + - id + - url + - publiccodeYml + - createdAt + - updatedAt Publisher: title: Publisher type: object diff --git a/go.mod b/go.mod index 8295cae..c8113d1 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,8 @@ require ( gorm.io/gorm v1.23.6 ) +require golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 + require ( github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect github.com/aead/chacha20poly1305 v0.0.0-20201124145622-1a5aba2a8b29 // indirect @@ -48,7 +50,7 @@ require ( github.com/valyala/fasthttp v1.38.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect - golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e // indirect + golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f // indirect golang.org/x/text v0.3.7 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f1e5b57..bae7a9b 100644 --- a/go.sum +++ b/go.sum @@ -230,6 +230,8 @@ golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d h1:sK3txAijHtOK88l68nt020reeT1ZdKLIYetKl95FzVY= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/exp v0.0.0-20220827204233-334a2380cb91 h1:tnebWN09GYg9OLPss1KXj8txwZc6X6uMr6VFdcGNbHw= +golang.org/x/exp v0.0.0-20220827204233-334a2380cb91/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= @@ -259,8 +261,8 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20210806184541-e5e7981a1069/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e h1:CsOuNlbOuf0mzxJIefr6Q4uAUetRUwZE4qt7VfzP+xo= -golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f h1:v4INt8xihDGvnrfjMDVXGxw9wrfxYyCjk0KbXjhR55s= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= diff --git a/internal/common/requests.go b/internal/common/requests.go index e6446a2..65f2212 100644 --- a/internal/common/requests.go +++ b/internal/common/requests.go @@ -12,12 +12,20 @@ type CodeHosting struct { Group *bool `json:"group"` } -type Software struct { - URLs []string `json:"urls" validate:"required,gt=0,dive,url"` +type SoftwarePost struct { + URL string `json:"url" validate:"required,url"` + Aliases []string `json:"aliases" validate:"dive,url"` PubliccodeYml string `json:"publiccodeYml" validate:"required"` Active *bool `json:"active"` } +type SoftwarePatch struct { + URL string `json:"url" validate:"url"` + Aliases *[]string `json:"aliases" validate:"omitempty,dive,url"` + PubliccodeYml string `json:"publiccodeYml"` + Active *bool `json:"active"` +} + type Log struct { Message string `json:"message" validate:"required,gt=1"` } diff --git a/internal/handlers/software.go b/internal/handlers/software.go index 62ef4b5..a1511d8 100644 --- a/internal/handlers/software.go +++ b/internal/handlers/software.go @@ -2,6 +2,9 @@ package handlers import ( "errors" + "sort" + + "golang.org/x/exp/slices" "github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2/utils" @@ -28,10 +31,12 @@ func NewSoftware(db *gorm.DB) *Software { } // GetAllSoftware gets the list of all software and returns any error encountered. -func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { +func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { //nolint:cyclop // mostly error handling ifs var software []models.Software - stmt := p.db.Preload("URLs") + // Preload will load all the associated aliases, which include + // also the canonical url. We'll manually handle that later. + stmt := p.db.Preload("Aliases") stmt, err := general.Clauses(ctx, stmt, "") if err != nil { @@ -42,10 +47,6 @@ func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { ) } - if all := ctx.Query("all", ""); all == "" { - stmt = stmt.Scopes(models.Active) - } - // Return just software with a certain URL if the 'url' query filter // is used. if url := ctx.Query("url", ""); url != "" { @@ -61,6 +62,10 @@ func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { } stmt.Where("id = ?", softwareURL.SoftwareID) + } else { + if all := ctx.Query("all", ""); all == "" { + stmt = stmt.Scopes(models.Active) + } } paginator := general.NewPaginator(ctx) @@ -82,6 +87,26 @@ func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { ) } + // Remove the canonical URL from the aliases, because it need to be its own + // field. It was loaded previously together with the other aliases in Preload(), + // because of limitation in gorm. + for swIdx := range software { + swr := &software[swIdx] + + for aliasIdx := range swr.Aliases { + alias := &swr.Aliases[aliasIdx] + + if alias.ID == swr.SoftwareURLID { + swr.URL = *alias + + swr.Aliases[aliasIdx] = swr.Aliases[len(swr.Aliases)-1] + swr.Aliases = swr.Aliases[:len(swr.Aliases)-1] + + break + } + } + } + return ctx.JSON(fiber.Map{"data": &software, "links": general.PaginationLinks(cursor)}) } @@ -89,7 +114,7 @@ func (p *Software) GetAllSoftware(ctx *fiber.Ctx) error { func (p *Software) GetSoftware(ctx *fiber.Ctx) error { software := models.Software{} - if err := p.db.Preload("URLs").First(&software, "id = ?", ctx.Params("id")).Error; err != nil { + if err := p.db.First(&software, "id = ?", ctx.Params("id")).Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.Error(fiber.StatusNotFound, "can't get Software", "Software was not found") } @@ -101,12 +126,30 @@ func (p *Software) GetSoftware(ctx *fiber.Ctx) error { ) } + if err := p.db. + Where("software_id = ? AND id <> ?", software.ID, software.SoftwareURLID).Find(&software.Aliases). + Error; err != nil { + return common.Error( + fiber.StatusInternalServerError, + "can't get Software", + fiber.ErrInternalServerError.Message, + ) + } + + if err := p.db.Where("id = ?", software.SoftwareURLID).First(&software.URL).Error; err != nil { + return common.Error( + fiber.StatusInternalServerError, + "can't get Software", + fiber.ErrInternalServerError.Message, + ) + } + return ctx.JSON(&software) } // PostSoftware creates a new software. func (p *Software) PostSoftware(ctx *fiber.Ctx) error { - softwareReq := new(common.Software) + softwareReq := new(common.SoftwarePost) if err := ctx.BodyParser(&softwareReq); err != nil { return common.Error(fiber.StatusBadRequest, "can't create Software", "invalid json") @@ -118,14 +161,20 @@ func (p *Software) PostSoftware(ctx *fiber.Ctx) error { ) } - softwareURLs := []models.SoftwareURL{} - for _, u := range softwareReq.URLs { - softwareURLs = append(softwareURLs, models.SoftwareURL{ID: utils.UUIDv4(), URL: u}) + aliases := []models.SoftwareURL{} + for _, u := range softwareReq.Aliases { + aliases = append(aliases, models.SoftwareURL{ID: utils.UUIDv4(), URL: u}) } + url := models.SoftwareURL{ID: utils.UUIDv4(), URL: softwareReq.URL} software := models.Software{ - ID: utils.UUIDv4(), - URLs: softwareURLs, + ID: utils.UUIDv4(), + + // Manually set the URL and its foreign key because of a limitation in gorm + URL: url, + SoftwareURLID: url.ID, + + Aliases: aliases, PubliccodeYml: softwareReq.PubliccodeYml, Active: softwareReq.Active, } @@ -138,12 +187,14 @@ func (p *Software) PostSoftware(ctx *fiber.Ctx) error { } // PatchSoftware updates the software with the given ID. -func (p *Software) PatchSoftware(ctx *fiber.Ctx) error { - softwareReq := new(common.Software) - +func (p *Software) PatchSoftware(ctx *fiber.Ctx) error { //nolint:cyclop // mostly error handling ifs + softwareReq := new(common.SoftwarePatch) software := models.Software{} - if err := p.db.First(&software, "id = ?", ctx.Params("id")).Error; err != nil { + // Preload will load all the associated aliases, which include + // also the canonical url. We'll manually handle that later. + if err := p.db.Preload("Aliases").First(&software, "id = ?", ctx.Params("id")). + Error; err != nil { if errors.Is(err, gorm.ErrRecordNotFound) { return common.Error(fiber.StatusNotFound, "can't update Software", "Software was not found") } @@ -161,19 +212,61 @@ func (p *Software) PatchSoftware(ctx *fiber.Ctx) error { ) } - softwareURLs := []models.SoftwareURL{} - for _, u := range softwareReq.URLs { - softwareURLs = append(softwareURLs, models.SoftwareURL{ID: utils.UUIDv4(), URL: u}) + // Slice of urls that we expect in the database after the PATCH (url + aliases) + var expectedURLs []string + + // application/merge-patch+json semantics: change url only if + // the request specifies an "url" key. + url := software.URL.URL + if softwareReq.URL != "" { + url = softwareReq.URL } - software.URLs = softwareURLs - software.PubliccodeYml = softwareReq.PubliccodeYml - software.Active = softwareReq.Active + // application/merge-patch+json semantics: change aliases only if + // the request specifies an "aliases" key. + if softwareReq.Aliases != nil { + expectedURLs = *softwareReq.Aliases + } else { + for _, alias := range software.Aliases { + expectedURLs = append(expectedURLs, alias.URL) + } + } - if err := p.db.Updates(&software).Error; err != nil { - return common.Error(fiber.StatusInternalServerError, "can't update Software", "db error") + expectedURLs = append(expectedURLs, url) + + if err := p.db.Transaction(func(tran *gorm.DB) error { + updatedURL, aliases, err := syncAliases(tran, software, url, expectedURLs) + if err != nil { + return err + } + + software.PubliccodeYml = softwareReq.PubliccodeYml + software.Active = softwareReq.Active + + // Manually set the canonical URL via the foreign key because of a limitation in gorm + software.SoftwareURLID = updatedURL.ID + software.URL = *updatedURL + + // Set Aliases to a zero value, so it's not touched by gorm's Update(), + // because we handle the alias manually + software.Aliases = []models.SoftwareURL{} + + if err := tran.Updates(&software).Error; err != nil { + return err + } + + software.Aliases = aliases + + return nil + }); err != nil { + return common.Error(fiber.StatusInternalServerError, "can't update Software", err.Error()) } + // Sort the aliases to always have a consistent output + sort.Slice(software.Aliases, func(a int, b int) bool { + return software.Aliases[a].URL < software.Aliases[b].URL + }) + return ctx.JSON(&software) } @@ -189,3 +282,66 @@ func (p *Software) DeleteSoftware(ctx *fiber.Ctx) error { return ctx.Status(fiber.StatusNoContent).JSON("") } + +// syncAliases synchs the SoftwareURLs for a `software` in the database to reflect the +// passed list of `expectedURLs` and the canonical `url`. +// +// It returns the new canonical SoftwareURL and the new slice of aliases or an error if any. +func syncAliases( //nolint:cyclop // mostly error handling ifs + gormdb *gorm.DB, software models.Software, canonicalURL string, expectedURLs []string, +) (*models.SoftwareURL, []models.SoftwareURL, error) { + toRemove := []string{} // Slice of SoftwareURL ids to remove from the database + toAdd := []models.SoftwareURL{} // Slice of SoftwareURLs to add to the database + + // Map mirroring the state of SoftwareURLs for this software in the database, + // keyed by url + urlMap := map[string]models.SoftwareURL{} + + for _, alias := range software.Aliases { + urlMap[alias.URL] = alias + } + + for url, alias := range urlMap { + if !slices.Contains(expectedURLs, url) { + toRemove = append(toRemove, alias.ID) + + delete(urlMap, url) + } + } + + for _, url := range expectedURLs { + _, exists := urlMap[url] + if !exists { + alias := models.SoftwareURL{ID: utils.UUIDv4(), URL: url, SoftwareID: software.ID} + + toAdd = append(toAdd, alias) + urlMap[url] = alias + } + } + + if len(toRemove) > 0 { + if err := gormdb.Delete(&models.SoftwareURL{}, toRemove).Error; err != nil { + return nil, nil, err + } + } + + if len(toAdd) > 0 { + if err := gormdb.Create(toAdd).Error; err != nil { + return nil, nil, err + } + } + + updatedCanonicalURL := urlMap[canonicalURL] + + // Remove the canonical URL from the aliases, because it need to be its own + // field. It was loaded previously together with the other aliases in Preload(), + // because of limitation in gorm. + delete(urlMap, canonicalURL) + + aliases := make([]models.SoftwareURL, 0, len(urlMap)) + for _, alias := range urlMap { + aliases = append(aliases, alias) + } + + return &updatedCanonicalURL, aliases, nil +} diff --git a/internal/models/models.go b/internal/models/models.go index e6e0bb3..f594af5 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -63,14 +63,20 @@ type CodeHosting struct { } type Software struct { - ID string `json:"id" gorm:"primaryKey"` - URLs []SoftwareURL `json:"urls"` - PubliccodeYml string `json:"publiccodeYml"` - Logs []Log `json:"-" gorm:"polymorphic:Entity;"` - Active *bool `json:"active" gorm:"default:true;not null"` - CreatedAt time.Time `json:"createdAt" gorm:"index"` - UpdatedAt time.Time `json:"updatedAt"` - DeletedAt gorm.DeletedAt `json:"-" gorm:"index"` + ID string `json:"id" gorm:"primarykey"` + + // This needs to be explicitly declared, otherwise GORM won't create + // the foreign key and will be confused about the double relationship + // with SoftwareURLs (belongs to and has many). + SoftwareURLID string `json:"-" gorm:"uniqueIndex,not null"` + + URL SoftwareURL `json:"url"` + Aliases []SoftwareURL `json:"aliases"` + PubliccodeYml string `json:"publiccodeYml"` + Logs []Log `json:"-" gorm:"polymorphic:Entity;"` + Active *bool `json:"active" gorm:"default:true;not null"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` } func (Software) TableName() string { @@ -83,10 +89,11 @@ func (s Software) UUID() string { } type SoftwareURL struct { - gorm.Model - ID string `gorm:"primaryKey"` - URL string `gorm:"uniqueIndex"` - SoftwareID string + ID string `gorm:"primarykey"` + URL string `gorm:"uniqueIndex"` + SoftwareID string `gorm:"not null"` + CreatedAt time.Time `json:"createdAt" gorm:"index"` + UpdatedAt time.Time `json:"updatedAt"` } func (su SoftwareURL) MarshalJSON() ([]byte, error) { diff --git a/internal/models/models_test.go b/internal/models/models_test.go index 2651dcc..c294fd7 100644 --- a/internal/models/models_test.go +++ b/internal/models/models_test.go @@ -79,7 +79,7 @@ func TestSoftwareCreate(t *testing.T) { err := db.Create( &Software{ ID: utils.UUIDv4(), - URLs: []SoftwareURL{{ID: utils.UUIDv4(), URL: "https://example.org"}}, + URL: SoftwareURL{ID: utils.UUIDv4(), URL: "https://example.org"}, PubliccodeYml: "-", }, ).Error @@ -89,7 +89,7 @@ func TestSoftwareCreate(t *testing.T) { err = db.Create( &Software{ ID: "c353756e-8597-4e46-a99b-7da2e141603b", - URLs: []SoftwareURL{{ID: utils.UUIDv4(), URL: "https://example.org"}}, + URL: SoftwareURL{ID: utils.UUIDv4(), URL: "https://example.org"}, PubliccodeYml: "-", }, ).Error diff --git a/main_test.go b/main_test.go index 63ca027..e808b7e 100644 --- a/main_test.go +++ b/main_test.go @@ -972,8 +972,10 @@ func TestSoftwareEndpoints(t *testing.T) { firstSoftware := data[0].(map[string]interface{}) assert.NotEmpty(t, firstSoftware["publiccodeYml"]) - assert.IsType(t, []interface{}{}, firstSoftware["urls"]) - assert.Greater(t, len(firstSoftware["urls"].([]interface{})), 0) + assert.Equal(t, "https://1-a.example.org/code/repo", firstSoftware["url"]) + + assert.IsType(t, []interface{}{}, firstSoftware["aliases"]) + assert.Equal(t, 1, len(firstSoftware["aliases"].([]interface{}))) match, err := regexp.MatchString(UUID_REGEXP, firstSoftware["id"].(string)) assert.Nil(t, err) @@ -984,8 +986,10 @@ func TestSoftwareEndpoints(t *testing.T) { _, err = time.Parse(time.RFC3339, firstSoftware["updatedAt"].(string)) assert.Nil(t, err) + assert.Equal(t, true, firstSoftware["active"]) + for key := range firstSoftware { - assert.Contains(t, []string{"id", "createdAt", "updatedAt", "urls", "publiccodeYml", "active"}, key) + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) } }, }, @@ -1012,20 +1016,24 @@ func TestSoftwareEndpoints(t *testing.T) { firstSoftware := data[0].(map[string]interface{}) assert.NotEmpty(t, firstSoftware["publiccodeYml"]) - assert.IsType(t, []interface{}{}, firstSoftware["urls"]) - assert.Greater(t, len(firstSoftware["urls"].([]interface{})), 0) + assert.Equal(t, "https://1-a.example.org/code/repo", firstSoftware["url"]) + + assert.IsType(t, []interface{}{}, firstSoftware["aliases"]) + assert.Equal(t, 1, len(firstSoftware["aliases"].([]interface{}))) match, err := regexp.MatchString(UUID_REGEXP, firstSoftware["id"].(string)) assert.Nil(t, err) assert.True(t, match) + assert.Equal(t, true, firstSoftware["active"]) + _, err = time.Parse(time.RFC3339, firstSoftware["createdAt"].(string)) assert.Nil(t, err) _, err = time.Parse(time.RFC3339, firstSoftware["updatedAt"].(string)) assert.Nil(t, err) for key := range firstSoftware { - assert.Contains(t, []string{"id", "createdAt", "updatedAt", "urls", "publiccodeYml", "active"}, key) + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) } }, }, @@ -1052,8 +1060,10 @@ func TestSoftwareEndpoints(t *testing.T) { firstSoftware := data[0].(map[string]interface{}) assert.NotEmpty(t, firstSoftware["publiccodeYml"]) - assert.IsType(t, []interface{}{}, firstSoftware["urls"]) - assert.Greater(t, len(firstSoftware["urls"].([]interface{})), 0) + assert.Equal(t, "https://1-a.example.org/code/repo", firstSoftware["url"]) + + assert.IsType(t, []interface{}{}, firstSoftware["aliases"]) + assert.Equal(t, 1, len(firstSoftware["aliases"].([]interface{}))) match, err := regexp.MatchString(UUID_REGEXP, firstSoftware["id"].(string)) assert.Nil(t, err) @@ -1065,7 +1075,7 @@ func TestSoftwareEndpoints(t *testing.T) { assert.Nil(t, err) for key := range firstSoftware { - assert.Contains(t, []string{"id", "createdAt", "updatedAt", "urls", "publiccodeYml", "active"}, key) + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) } }, }, @@ -1092,8 +1102,10 @@ func TestSoftwareEndpoints(t *testing.T) { firstSoftware := data[0].(map[string]interface{}) assert.Equal(t, "-", firstSoftware["publiccodeYml"]) - assert.IsType(t, []interface{}{}, firstSoftware["urls"]) - assert.Equal(t, 2, len(firstSoftware["urls"].([]interface{}))) + assert.Equal(t, "https://1-a.example.org/code/repo", firstSoftware["url"]) + + assert.IsType(t, []interface{}{}, firstSoftware["aliases"]) + assert.Equal(t, 1, len(firstSoftware["aliases"].([]interface{}))) assert.Equal(t, "c353756e-8597-4e46-a99b-7da2e141603b", firstSoftware["id"]) @@ -1101,7 +1113,7 @@ func TestSoftwareEndpoints(t *testing.T) { assert.Equal(t, "2014-05-01T00:00:00Z", firstSoftware["updatedAt"]) for key := range firstSoftware { - assert.Contains(t, []string{"id", "createdAt", "updatedAt", "urls", "publiccodeYml", "active"}, key) + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) } }, }, @@ -1288,8 +1300,10 @@ func TestSoftwareEndpoints(t *testing.T) { validateFunc: func(t *testing.T, response map[string]interface{}) { assert.NotEmpty(t, response["publiccodeYml"]) - assert.IsType(t, []interface{}{}, response["urls"]) - assert.Greater(t, len(response["urls"].([]interface{})), 0) + assert.Equal(t, "https://8-a.example.org/code/repo", response["url"]) + + assert.IsType(t, []interface{}{}, response["aliases"]) + assert.Equal(t, 1, len(response["aliases"].([]interface{}))) match, err := regexp.MatchString(UUID_REGEXP, response["id"].(string)) assert.Nil(t, err) @@ -1301,7 +1315,7 @@ func TestSoftwareEndpoints(t *testing.T) { assert.Nil(t, err) for key := range response { - assert.Contains(t, []string{"id", "createdAt", "updatedAt", "urls", "publiccodeYml", "active"}, key) + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) } }, }, @@ -1309,20 +1323,21 @@ func TestSoftwareEndpoints(t *testing.T) { // POST /software { query: "POST /v1/software", - body: `{"publiccodeYml": "-", "urls": ["https://software.example.org"]}`, + body: `{"publiccodeYml": "-", "url": "https://software.example.org"}`, headers: map[string][]string{ "Authorization": {goodToken}, "Content-Type": {"application/json"}, }, + fixtures: []string{"software.yml", "software_urls.yml"}, expectedCode: 200, expectedContentType: "application/json", validateFunc: func(t *testing.T, response map[string]interface{}) { - assert.IsType(t, []interface{}{}, response["urls"]) - assert.Equal(t, 1, len(response["urls"].([]interface{}))) - - // TODO: check urls content + assert.Equal(t, "https://software.example.org", response["url"]) assert.NotEmpty(t, response["publiccodeYml"]) + assert.IsType(t, []interface{}{}, response["aliases"]) + assert.Empty(t, response["aliases"].([]interface{})) + match, err := regexp.MatchString(UUID_REGEXP, response["id"].(string)) assert.Nil(t, err) assert.True(t, match) @@ -1335,10 +1350,56 @@ func TestSoftwareEndpoints(t *testing.T) { assert.Equal(t, true, response["active"]) + for key := range response { + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) + } + + // TODO: check the record was actually created in the database + // TODO: check there are no dangling software_urls + }, + }, + { + description: "POST software with aliases", + query: "POST /v1/software", + body: `{"publiccodeYml": "-", "url": "https://software.example.org", "aliases": ["https://software-1.example.org", "https://software-2.example.org"]}`, + headers: map[string][]string{ + "Authorization": {goodToken}, + "Content-Type": {"application/json"}, + }, + fixtures: []string{"software.yml", "software_urls.yml"}, + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]interface{}) { + assert.Equal(t, "https://software.example.org", response["url"]) + assert.NotEmpty(t, response["publiccodeYml"]) + + assert.IsType(t, []interface{}{}, response["aliases"]) + + aliases := response["aliases"].([]interface{}) + assert.Equal(t, 2, len(aliases)) + + assert.Equal(t, "https://software-1.example.org", aliases[0]) + assert.Equal(t, "https://software-2.example.org", aliases[1]) + + match, err := regexp.MatchString(UUID_REGEXP, response["id"].(string)) + assert.Nil(t, err) + assert.True(t, match) + + _, err = time.Parse(time.RFC3339, response["createdAt"].(string)) + assert.Nil(t, err) + + _, err = time.Parse(time.RFC3339, response["updatedAt"].(string)) + assert.Nil(t, err) + + for key := range response { + assert.Contains(t, []string{"id", "createdAt", "updatedAt", "url", "aliases", "publiccodeYml", "active"}, key) + } + // TODO: check the record was actually created in the database // TODO: check there are no dangling software_urls }, }, + { description: "POST software with invalid payload", query: "POST /v1/software", @@ -1349,12 +1410,12 @@ func TestSoftwareEndpoints(t *testing.T) { }, expectedCode: 422, expectedContentType: "application/problem+json", - expectedBody: `{"title":"can't create Software","detail":"invalid format","status":422,"validationErrors":[{"field":"urls","rule":"required"}]}`, + expectedBody: `{"title":"can't create Software","detail":"invalid format","status":422,"validationErrors":[{"field":"url","rule":"required"}]}`, }, { description: "POST software - wrong token", query: "POST /v1/software", - body: `{"publiccodeYml": "-", "urls": ["https://software.example.org"]}`, + body: `{"publiccodeYml": "-", "url": "https://software.example.org"}`, headers: map[string][]string{ "Authorization": {badToken}, "Content-Type": {"application/json"}, @@ -1396,7 +1457,7 @@ func TestSoftwareEndpoints(t *testing.T) { { description: "POST software with optional boolean field set to false", query: "POST /v1/software", - body: `{"active": false, "urls":["https://example.org"], "publiccodeYml": "-"}`, + body: `{"active": false, "url": "https://example.org", "publiccodeYml": "-"}`, headers: map[string][]string{ "Authorization": {goodToken}, "Content-Type": {"application/json"}, @@ -1473,7 +1534,83 @@ func TestSoftwareEndpoints(t *testing.T) { }, { query: "PATCH /v1/software/59803fb7-8eec-4fe5-a354-8926009c364a", - body: `{"publiccodeYml": "publiccodedata", "urls": ["https://software.example.org", "https://sofware-old.example.com", "https://software-old.example.org"]}`, + body: `{"publiccodeYml": "publiccodedata", "url": "https://software-new.example.org", "aliases": ["https://software.example.com", "https://software-old.example.org"]}`, + headers: map[string][]string{ + "Authorization": {goodToken}, + "Content-Type": {"application/json"}, + }, + fixtures: []string{"software.yml", "software_urls.yml"}, + + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]interface{}) { + assert.Equal(t, "https://software-new.example.org", response["url"]) + + assert.IsType(t, []interface{}{}, response["aliases"]) + + aliases := response["aliases"].([]interface{}) + assert.Equal(t, 2, len(aliases)) + + assert.Equal(t, "https://software-old.example.org", aliases[0]) + assert.Equal(t, "https://software.example.com", aliases[1]) + + assert.Equal(t, "publiccodedata", response["publiccodeYml"]) + + match, err := regexp.MatchString(UUID_REGEXP, response["id"].(string)) + assert.Nil(t, err) + assert.True(t, match) + + created, err := time.Parse(time.RFC3339, response["createdAt"].(string)) + assert.Nil(t, err) + + updated, err := time.Parse(time.RFC3339, response["updatedAt"].(string)) + assert.Nil(t, err) + + assert.Greater(t, updated, created) + }, + }, + { + description: "PATCH software with no aliases (should leave current aliases untouched)", + query: "PATCH /v1/software/59803fb7-8eec-4fe5-a354-8926009c364a", + body: `{"publiccodeYml": "publiccodedata", "url": "https://software-new.example.org"}`, + headers: map[string][]string{ + "Authorization": {goodToken}, + "Content-Type": {"application/json"}, + }, + fixtures: []string{"software.yml", "software_urls.yml"}, + + expectedCode: 200, + expectedContentType: "application/json", + validateFunc: func(t *testing.T, response map[string]interface{}) { + assert.Equal(t, "https://software-new.example.org", response["url"]) + + assert.IsType(t, []interface{}{}, response["aliases"]) + + aliases := response["aliases"].([]interface{}) + assert.Equal(t, 2, len(aliases)) + + assert.Equal(t, "https://18-a.example.org/code/repo", aliases[0]) + assert.Equal(t, "https://18-b.example.org/code/repo", aliases[1]) + + assert.Equal(t, "publiccodedata", response["publiccodeYml"]) + + match, err := regexp.MatchString(UUID_REGEXP, response["id"].(string)) + assert.Nil(t, err) + assert.True(t, match) + + created, err := time.Parse(time.RFC3339, response["createdAt"].(string)) + assert.Nil(t, err) + + updated, err := time.Parse(time.RFC3339, response["updatedAt"].(string)) + assert.Nil(t, err) + + assert.Greater(t, updated, created) + }, + }, + { + description: "PATCH software with empty aliases (should remove aliases)", + query: "PATCH /v1/software/59803fb7-8eec-4fe5-a354-8926009c364a", + body: `{"publiccodeYml": "publiccodedata", "url": "https://software-new.example.org", "aliases": []}`, headers: map[string][]string{ "Authorization": {goodToken}, "Content-Type": {"application/json"}, @@ -1483,9 +1620,12 @@ func TestSoftwareEndpoints(t *testing.T) { expectedCode: 200, expectedContentType: "application/json", validateFunc: func(t *testing.T, response map[string]interface{}) { - assert.IsType(t, []interface{}{}, response["urls"]) - assert.Equal(t, 3, len(response["urls"].([]interface{}))) - // TODO: check urls content + assert.Equal(t, "https://software-new.example.org", response["url"]) + + assert.IsType(t, []interface{}{}, response["aliases"]) + + aliases := response["aliases"].([]interface{}) + assert.Equal(t, 0, len(aliases)) assert.Equal(t, "publiccodedata", response["publiccodeYml"]) @@ -1551,7 +1691,7 @@ func TestSoftwareEndpoints(t *testing.T) { description: "PATCH software with validation errors", query: "PATCH /v1/software/59803fb7-8eec-4fe5-a354-8926009c364a", fixtures: []string{"software.yml", "software_urls.yml"}, - body: `{"urls": ["INVALID_URL"], "publiccodeYml": "-"}`, + body: `{"url": "INVALID_URL", "publiccodeYml": "-"}`, headers: map[string][]string{ "Authorization": {goodToken}, "Content-Type": {"application/json"}, diff --git a/test/testdata/fixtures/software.yml b/test/testdata/fixtures/software.yml index a49f48c..bbc73c9 100644 --- a/test/testdata/fixtures/software.yml +++ b/test/testdata/fixtures/software.yml @@ -1,128 +1,159 @@ --- - id: c353756e-8597-4e46-a99b-7da2e141603b publiccode_yml: "-" + software_url_id: beeadd3e-11bb-4313-99bb-94cd51836926 created_at: '2014-05-01T00:00:00+00:00' updated_at: '2014-05-01T00:00:00+00:00' - id: 9f135268-a37e-4ead-96ec-e4a24bb9344a publiccode_yml: "-" + software_url_id: f22d408f-93a5-411c-9c35-99039514afc4 created_at: '2014-05-16T00:00:00+00:00' updated_at: '2014-05-16T00:00:00+00:00' - id: 18348f13-1076-4a1e-b204-ed541b824d64 publiccode_yml: "-" + software_url_id: 4a542d47-b249-4193-81d2-6adef5beec55 created_at: '2014-05-31T00:00:00+00:00' updated_at: '2014-05-31T00:00:00+00:00' - id: 3eff1b39-8dd3-4871-9fec-32a3172510f1 publiccode_yml: "-" + software_url_id: 5237ce3c-6b61-45de-8888-8fad01239bdf created_at: '2014-06-15T00:00:00+00:00' updated_at: '2014-06-15T00:00:00+00:00' - id: 76d50e86-09c2-4dc3-ae95-21d44d8d7610 publiccode_yml: "-" + software_url_id: 316a6051-e04b-4061-b888-889697fb94f7 created_at: '2014-06-30T00:00:00+00:00' updated_at: '2014-06-30T00:00:00+00:00' - id: b23cba1f-4e59-4454-98cd-0757b29a3ba0 publiccode_yml: "-" + software_url_id: 8f3bc024-b71a-4377-bbb9-70a415bdb72b created_at: '2014-07-15T00:00:00+00:00' updated_at: '2014-07-15T00:00:00+00:00' - id: b05bb541-bc8e-4af0-834a-1b7be1e0a6cf publiccode_yml: "-" + software_url_id: bf2dca9b-52b1-4b69-8806-a2b8357c3957 created_at: '2014-07-30T00:00:00+00:00' updated_at: '2014-07-30T00:00:00+00:00' - id: e7576e7f-9dcf-4979-b9e9-d8cdcad3b60e publiccode_yml: "-" + software_url_id: d20cf72c-dbe8-4ba7-ac30-8f1a24ca872e created_at: '2014-08-14T00:00:00+00:00' updated_at: '2014-08-14T00:00:00+00:00' - id: 11e101c4-f989-4cc4-a665-63f9f34e83f6 publiccode_yml: "-" + software_url_id: 18778680-fcc9-4c55-a933-3d9b961c8ef6 created_at: '2014-08-29T00:00:00+00:00' updated_at: '2014-08-29T00:00:00+00:00' - id: f026fdaa-8682-40d1-b26e-0d7a3abf1946 publiccode_yml: "-" + software_url_id: bd96fadd-2e61-43c5-8072-f2e2494922f6 created_at: '2014-09-13T00:00:00+00:00' updated_at: '2014-09-13T00:00:00+00:00' - id: 59f32fa2-6896-4654-969c-c69d161d84de publiccode_yml: "-" + software_url_id: 5d82cfa3-b9ac-47fb-a6bd-fa72d4979a9f created_at: '2014-09-28T00:00:00+00:00' updated_at: '2014-09-28T00:00:00+00:00' - id: 1782738e-8528-4506-a558-a2a3f3489ddf publiccode_yml: "-" + software_url_id: 410bca46-de7f-49cf-a159-5612f5588264 created_at: '2014-10-13T00:00:00+00:00' updated_at: '2014-10-13T00:00:00+00:00' - id: 621a2af2-dc65-4a15-b7e8-33544b3415bc publiccode_yml: "-" + software_url_id: 0a697b70-4b58-4f69-a0b9-7edae98fb8f8 created_at: '2014-10-28T00:00:00+00:00' updated_at: '2014-10-28T00:00:00+00:00' - id: 79d7dd0b-8c7f-4299-be00-4a226a20657e publiccode_yml: "-" + software_url_id: 1888a9dd-0ad7-4d11-bf30-8d80f98d94f6 created_at: '2014-11-12T00:00:00+00:00' updated_at: '2014-11-12T00:00:00+00:00' - id: e8b1c04f-f4cb-4ded-ae2f-b76927cf78da publiccode_yml: "-" + software_url_id: 63f7fe58-5745-459f-aa94-7bde30107ff8 created_at: '2014-11-27T00:00:00+00:00' updated_at: '2014-11-27T00:00:00+00:00' - id: aefab093-9082-4dfc-839b-17db4b98d804 publiccode_yml: "-" + software_url_id: dbc09e58-53c6-4d30-9674-07efc28f8770 created_at: '2014-12-12T00:00:00+00:00' updated_at: '2014-12-12T00:00:00+00:00' - id: 975a3ee7-0c9d-45e8-ba90-85613afcb9fc publiccode_yml: "-" + software_url_id: e6a622c0-8bd6-44fa-bf0f-52e2e813a550 created_at: '2014-12-27T00:00:00+00:00' updated_at: '2014-12-27T00:00:00+00:00' - id: 59803fb7-8eec-4fe5-a354-8926009c364a publiccode_yml: "-" + software_url_id: 73d86c54-6880-4763-b872-cc652b52d1df created_at: '2015-01-11T00:00:00+00:00' updated_at: '2015-01-11T00:00:00+00:00' - id: a6c805ed-4edb-41a3-826e-900c0f85353b publiccode_yml: "-" + software_url_id: ff242b9e-2417-437e-bcbf-7d08c0492935 created_at: '2015-01-26T00:00:00+00:00' updated_at: '2015-01-26T00:00:00+00:00' - id: 7902ff36-a23e-4d34-a906-91bbb57542fa publiccode_yml: "-" + software_url_id: c337be5f-83d5-4aff-99f9-8e0d2517093f created_at: '2015-02-10T00:00:00+00:00' updated_at: '2015-02-10T00:00:00+00:00' - id: c5dec6fa-8a01-4881-9e7d-132770d4214d publiccode_yml: "-" + software_url_id: 32746296-1321-47d7-9bf2-466acf144ea4 created_at: '2015-02-25T00:00:00+00:00' updated_at: '2015-02-25T00:00:00+00:00' - id: 1e6e2df3-832f-434c-b70d-c2a3fb82df3e publiccode_yml: "-" + software_url_id: 54ce56bf-e53f-491e-8ce7-8ba3f97fa124 created_at: '2015-03-12T00:00:00+00:00' updated_at: '2015-03-12T00:00:00+00:00' - id: 7fab4edd-57b1-4c16-8365-5f6f63f73eda publiccode_yml: "-" + software_url_id: e8833d12-5d44-49bf-bf18-4e9dfa22234b created_at: '2015-03-27T00:00:00+00:00' updated_at: '2015-03-27T00:00:00+00:00' - id: 3ea10274-6676-43c0-aa20-030e826f7788 publiccode_yml: "-" + software_url_id: 087343d9-ecd2-4947-b251-b47efec10623 created_at: '2015-04-11T00:00:00+00:00' updated_at: '2015-04-11T00:00:00+00:00' - id: 124280d7-7552-4ffe-939f-f46697cc0e8a publiccode_yml: "-" + software_url_id: b3039f56-69a8-4244-8d97-5b6bb9fc289b created_at: '2015-04-26T00:00:00+00:00' updated_at: '2015-04-26T00:00:00+00:00' - id: 83e7a35e-328b-4891-b60b-59792e01c59e publiccode_yml: "-" + software_url_id: 8ca8b5d6-0ef7-4abd-b6b6-4f4f038e6a61 created_at: '2015-05-11T00:00:00+00:00' updated_at: '2015-05-11T00:00:00+00:00' - id: b6267714-4b2d-4b52-828d-b67340f0c588 publiccode_yml: "-" + software_url_id: 46e215a9-ec41-4e1b-aff4-07ad1ec49494 created_at: '2015-05-26T00:00:00+00:00' updated_at: '2015-05-26T00:00:00+00:00' - id: d5675fe6-f9d7-4fae-b270-5cd4918e4a30 publiccode_yml: "-" + software_url_id: 11c87e37-0dc9-48fa-8aaf-7d9308e9674a created_at: '2015-06-10T00:00:00+00:00' updated_at: '2015-06-10T00:00:00+00:00' - id: b9b6dbf2-f0e9-4797-a75a-88729e22a14c publiccode_yml: "-" + software_url_id: 736984a7-1c4e-453c-ab57-e7fa852fd272 created_at: '2015-06-25T00:00:00+00:00' updated_at: '2015-06-25T00:00:00+00:00' - id: 660aaa39-efc6-4272-b125-3b3775033503 publiccode_yml: "-" + software_url_id: 5d5c50de-772e-4266-a855-340e364de439 created_at: '2015-07-10T00:00:00+00:00' updated_at: '2015-07-10T00:00:00+00:00' # Software with active: false - id: 7fc258a8-28e0-443c-a889-5e835ce4a378 publiccode_yml: "-" + software_url_id: 5d9316d4-7f80-4723-86c6-5a2515a01b04 active: false created_at: '2015-07-15T00:00:00+00:00' updated_at: '2015-07-15T00:00:00+00:00' diff --git a/test/testdata/fixtures/software_urls.yml b/test/testdata/fixtures/software_urls.yml index 2b815c9..81180e4 100644 --- a/test/testdata/fixtures/software_urls.yml +++ b/test/testdata/fixtures/software_urls.yml @@ -299,3 +299,8 @@ url: https://30-b.example.org/code/repo created_at: '2015-07-10T00:00:00+00:00' updated_at: '2015-07-10T00:00:00+00:00' +- id: 5d9316d4-7f80-4723-86c6-5a2515a01b04 + software_id: 7fc258a8-28e0-443c-a889- + url: https://31-a.example.org/code/repo + created_at: '2015-07-10T00:00:00+00:00' + updated_at: '2015-07-10T00:00:00+00:00'