aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAnthony Wang2022-06-16 16:25:17 -0500
committerAnthony Wang2022-06-16 16:25:17 -0500
commitdbb1d5ba23e1fede7b505d0b1ee24bd4e9d64dd4 (patch)
tree05144ff7f0640ebe57fb2e6fec22772c6d09eea1
parentaf2419c5d767c45d58ab7066ef57cd6d80a369fc (diff)
Implement Repository actor endpoints
-rw-r--r--models/forgefed/forgefed.go26
-rw-r--r--models/forgefed/repository.go72
-rw-r--r--models/forgefed/repository_test.go167
-rw-r--r--routers/api/v1/activitypub/person.go54
-rw-r--r--routers/api/v1/activitypub/repo.go212
-rw-r--r--routers/api/v1/api.go7
-rw-r--r--templates/swagger/v1_json.tmpl112
7 files changed, 617 insertions, 33 deletions
diff --git a/models/forgefed/forgefed.go b/models/forgefed/forgefed.go
deleted file mode 100644
index 53c0db646..000000000
--- a/models/forgefed/forgefed.go
+++ /dev/null
@@ -1,26 +0,0 @@
-// 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 forgefed
-
-import (
- ap "github.com/go-ap/activitypub"
-)
-
-const (
- RepositoryType ap.ActivityVocabularyType = "Repository"
-)
-
-type Repository struct {
- ap.Actor
- // Team specifies a Collection of actors who are working on the object
- Team ap.Item `jsonld:"team,omitempty"`
-}
-
-// RepositoryNew initializes a Repository type actor
-func RepositoryNew(id ap.ID) *Repository {
- a := ap.ActorNew(id, RepositoryType)
- o := Repository{Actor: *a}
- return &o
-} \ No newline at end of file
diff --git a/models/forgefed/repository.go b/models/forgefed/repository.go
new file mode 100644
index 000000000..996d0edea
--- /dev/null
+++ b/models/forgefed/repository.go
@@ -0,0 +1,72 @@
+// 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 forgefed
+
+import (
+ "github.com/valyala/fastjson"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+const (
+ RepositoryType ap.ActivityVocabularyType = "Repository"
+)
+
+type Repository struct {
+ ap.Actor
+ // Team Collection of actors who have management/push access to the repository
+ Team ap.Item `jsonld:"team,omitempty"`
+ // Forks OrderedCollection of repositories that are forks of this repository
+ Forks ap.Item `jsonld:"forks,omitempty"`
+}
+
+// GetItemByType instantiates a new Repository object if the type matches
+// otherwise it defaults to existing activitypub package typer function.
+func GetItemByType(typ ap.ActivityVocabularyType) (ap.Item, error) {
+ if typ == RepositoryType {
+ return RepositoryNew(""), nil
+ }
+ return ap.GetItemByType(typ)
+}
+
+// RepositoryNew initializes a Repository type actor
+func RepositoryNew(id ap.ID) *Repository {
+ a := ap.ActorNew(id, RepositoryType)
+ o := Repository{Actor: *a}
+ o.Type = RepositoryType
+ return &o
+}
+
+func (r Repository) MarshalJSON() ([]byte, error) {
+ b, err := r.Actor.MarshalJSON()
+ if len(b) == 0 || err != nil {
+ return make([]byte, 0), err
+ }
+
+ b = b[:len(b)-1]
+ if r.Team != nil {
+ ap.WriteItemJSONProp(&b, "team", r.Team)
+ }
+ if r.Forks != nil {
+ ap.WriteItemJSONProp(&b, "forks", r.Forks)
+ }
+ ap.Write(&b, '}')
+ return b, nil
+}
+
+func (r *Repository) UnmarshalJSON(data []byte) error {
+ p := fastjson.Parser{}
+ val, err := p.ParseBytes(data)
+ if err != nil {
+ return err
+ }
+
+ r.Team = ap.JSONGetItem(val, "team")
+ r.Forks = ap.JSONGetItem(val, "forks")
+
+ return ap.OnActor(&r.Actor, func(a *ap.Actor) error {
+ return ap.LoadActor(val, a)
+ })
+}
diff --git a/models/forgefed/repository_test.go b/models/forgefed/repository_test.go
new file mode 100644
index 000000000..4bf36a66c
--- /dev/null
+++ b/models/forgefed/repository_test.go
@@ -0,0 +1,167 @@
+package forgefed
+
+import (
+ "encoding/json"
+ "fmt"
+ "reflect"
+ "testing"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+func Test_GetItemByType(t *testing.T) {
+ type testtt struct {
+ typ ap.ActivityVocabularyType
+ want ap.Item
+ wantErr error
+ }
+ tests := map[string]testtt{
+ "invalid type": {
+ typ: ap.ActivityVocabularyType("invalidtype"),
+ wantErr: fmt.Errorf("empty ActivityStreams type"), // TODO(marius): this error message needs to be improved in go-ap/activitypub
+ },
+ "Repository": {
+ typ: RepositoryType,
+ want: new(Repository),
+ },
+ "Person - fall back": {
+ typ: ap.PersonType,
+ want: new(ap.Person),
+ },
+ "Question - fall back": {
+ typ: ap.QuestionType,
+ want: new(ap.Question),
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ maybeRepository, err := GetItemByType(tt.typ)
+ if !reflect.DeepEqual(tt.wantErr, err) {
+ t.Errorf("GetItemByType() error = \"%+v\", wantErr = \"%+v\" when getting Item for type %q", tt.wantErr, err, tt.typ)
+ }
+ if reflect.TypeOf(tt.want) != reflect.TypeOf(maybeRepository) {
+ t.Errorf("Invalid type received %T, expected %T", maybeRepository, tt.want)
+ }
+ })
+ }
+}
+
+func Test_RepositoryMarshalJSON(t *testing.T) {
+ type testPair struct {
+ item Repository
+ want []byte
+ wantErr error
+ }
+
+ tests := map[string]testPair{
+ "empty": {
+ item: Repository{},
+ want: nil,
+ },
+ "with ID": {
+ item: Repository{
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ },
+ Team: nil,
+ },
+ want: []byte(`{"id":"https://example.com/1"}`),
+ },
+ "with Team as IRI": {
+ item: Repository{
+ Team: ap.IRI("https://example.com/1"),
+ },
+ want: []byte(`{"team":"https://example.com/1"}`),
+ },
+ "with Team as IRIs": {
+ item: Repository{
+ Team: ap.ItemCollection{
+ ap.IRI("https://example.com/1"),
+ ap.IRI("https://example.com/2"),
+ },
+ },
+ want: []byte(`{"team":["https://example.com/1","https://example.com/2"]}`),
+ },
+ "with Team as Object": {
+ item: Repository{
+ Team: ap.Object{ID: "https://example.com/1"},
+ },
+ want: []byte(`{"team":{"id":"https://example.com/1"}}`),
+ },
+ "with Team as slice of Objects": {
+ item: Repository{
+ Team: ap.ItemCollection{
+ ap.Object{ID: "https://example.com/1"},
+ ap.Object{ID: "https://example.com/2"},
+ },
+ },
+ want: []byte(`{"team":[{"id":"https://example.com/1"},{"id":"https://example.com/2"}]}`),
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ got, err := tt.item.MarshalJSON()
+ if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
+ t.Errorf("MarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
+ return
+ }
+ if !reflect.DeepEqual(got, tt.want) {
+ t.Errorf("MarshalJSON() got = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func Test_RepositoryUnmarshalJSON(t *testing.T) {
+ type testPair struct {
+ data []byte
+ want *Repository
+ wantErr error
+ }
+
+ tests := map[string]testPair{
+ "nil": {
+ data: nil,
+ wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
+ },
+ "empty": {
+ data: []byte{},
+ wantErr: fmt.Errorf("cannot parse JSON: %w", fmt.Errorf("cannot parse empty string; unparsed tail: %q", "")),
+ },
+ "with Type": {
+ data: []byte(`{"type":"Repository"}`),
+ want: &Repository{
+ Actor: ap.Actor{
+ Type: RepositoryType,
+ },
+ },
+ },
+ "with Type and ID": {
+ data: []byte(`{"id":"https://example.com/1","type":"Repository"}`),
+ want: &Repository{
+ Actor: ap.Actor{
+ ID: "https://example.com/1",
+ Type: RepositoryType,
+ },
+ },
+ },
+ }
+
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ got := new(Repository)
+ err := got.UnmarshalJSON(tt.data)
+ if (err != nil || tt.wantErr != nil) && tt.wantErr.Error() != err.Error() {
+ t.Errorf("UnmarshalJSON() error = \"%v\", wantErr \"%v\"", err, tt.wantErr)
+ return
+ }
+ if tt.want != nil && !reflect.DeepEqual(got, tt.want) {
+ jGot, _ := json.Marshal(got)
+ jWant, _ := json.Marshal(tt.want)
+ t.Errorf("UnmarshalJSON() got = %s, want %s", jGot, jWant)
+ }
+ })
+ }
+}
diff --git a/routers/api/v1/activitypub/person.go b/routers/api/v1/activitypub/person.go
index 8202ee15e..700d9ac0d 100644
--- a/routers/api/v1/activitypub/person.go
+++ b/routers/api/v1/activitypub/person.go
@@ -10,6 +10,8 @@ import (
"strings"
"code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/forgefed"
+ repo_model "code.gitea.io/gitea/models/repo"
user_model "code.gitea.io/gitea/models/user"
"code.gitea.io/gitea/modules/activitypub"
"code.gitea.io/gitea/modules/context"
@@ -82,7 +84,7 @@ func Person(ctx *context.APIContext) {
binary, err := person.MarshalJSON()
if err != nil {
- ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ ctx.Error(http.StatusInternalServerError, "MarshalJSON", err)
return
}
response(ctx, binary)
@@ -166,7 +168,7 @@ func PersonOutbox(ctx *context.APIContext) {
binary, err := outbox.MarshalJSON()
if err != nil {
- ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ ctx.Error(http.StatusInternalServerError, "MarshalJSON", err)
}
response(ctx, binary)
}
@@ -207,7 +209,7 @@ func PersonFollowing(ctx *context.APIContext) {
binary, err := following.MarshalJSON()
if err != nil {
- ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ ctx.Error(http.StatusInternalServerError, "MarshalJSON", err)
}
response(ctx, binary)
}
@@ -247,7 +249,51 @@ func PersonFollowers(ctx *context.APIContext) {
binary, err := followers.MarshalJSON()
if err != nil {
- ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ ctx.Error(http.StatusInternalServerError, "MarshalJSON", err)
+ }
+ response(ctx, binary)
+}
+
+// PersonLiked function returns the user's Liked Collection
+func PersonLiked(ctx *context.APIContext) {
+ // swagger:operation GET /activitypub/user/{username}/followers activitypub activitypubPersonLiked
+ // ---
+ // summary: Returns the Liked Collection
+ // produces:
+ // - application/activity+json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActivityPub"
+
+ link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
+
+ repos, count, err := repo_model.SearchRepository(&repo_model.SearchRepoOptions{
+ Actor: ctx.Doer,
+ Private: ctx.IsSigned,
+ StarredByID: ctx.ContextUser.ID,
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserStarred", err)
+ return
+ }
+
+ liked := ap.OrderedCollectionNew(ap.IRI(link))
+ liked.TotalItems = uint(count)
+
+ for _, repo := range repos {
+ repo := forgefed.RepositoryNew(ap.IRI(strings.TrimSuffix(setting.AppURL, "/") + "/api/v1/activitypub/user/" + repo.OwnerName + "/" + repo.Name))
+ liked.OrderedItems.Append(repo)
+ }
+
+ binary, err := liked.MarshalJSON()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "MarshalJSON", err)
}
response(ctx, binary)
}
diff --git a/routers/api/v1/activitypub/repo.go b/routers/api/v1/activitypub/repo.go
new file mode 100644
index 000000000..8952e06db
--- /dev/null
+++ b/routers/api/v1/activitypub/repo.go
@@ -0,0 +1,212 @@
+// 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 activitypub
+
+import (
+ "io"
+ "net/http"
+ "strings"
+
+ "code.gitea.io/gitea/models"
+ "code.gitea.io/gitea/models/forgefed"
+ //repo_model "code.gitea.io/gitea/models/repo"
+ user_model "code.gitea.io/gitea/models/user"
+ "code.gitea.io/gitea/modules/context"
+ "code.gitea.io/gitea/modules/log"
+ "code.gitea.io/gitea/modules/setting"
+ "code.gitea.io/gitea/routers/api/v1/utils"
+
+ ap "github.com/go-ap/activitypub"
+)
+
+// Repo function
+func Repo(ctx *context.APIContext) {
+ // swagger:operation GET /activitypub/user/{username}/{reponame} activitypub activitypubRepo
+ // ---
+ // summary: Returns the repository
+ // produces:
+ // - application/activity+json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // - name: reponame
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActivityPub"
+
+ link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
+ repo := forgefed.RepositoryNew(ap.IRI(link))
+
+ repo.Name = ap.NaturalLanguageValuesNew()
+ err := repo.Name.Set("en", ap.Content(ctx.Repo.Repository.Name))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Set Name", err)
+ return
+ }
+
+ repo.AttributedTo = ap.IRI(strings.TrimSuffix(link, "/"+ctx.Repo.Repository.Name))
+
+ repo.Summary = ap.NaturalLanguageValuesNew()
+ err = repo.Summary.Set("en", ap.Content(ctx.Repo.Repository.Description))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Set Description", err)
+ return
+ }
+
+ repo.Inbox = ap.IRI(link + "/inbox")
+ repo.Outbox = ap.IRI(link + "/outbox")
+ repo.Followers = ap.IRI(link + "/followers")
+ repo.Team = ap.IRI(link + "/team")
+
+ binary, err := repo.MarshalJSON()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ return
+ }
+ response(ctx, binary)
+}
+
+// RepoInbox function
+func RepoInbox(ctx *context.APIContext) {
+ // swagger:operation POST /activitypub/user/{username}/{reponame}/inbox activitypub activitypubRepoInbox
+ // ---
+ // summary: Send to the inbox
+ // produces:
+ // - application/activity+json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // - name: reponame
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // responses:
+ // "204":
+ // "$ref": "#/responses/empty"
+
+ body, err := io.ReadAll(ctx.Req.Body)
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Error reading request body", err)
+ }
+
+ var activity ap.Activity
+ activity.UnmarshalJSON(body)
+ if activity.Type == ap.FollowType {
+ //activitypub.Follow(ctx, activity)
+ } else {
+ log.Warn("ActivityStreams type not supported", activity)
+ }
+
+ ctx.Status(http.StatusNoContent)
+}
+
+// RepoOutbox function
+func RepoOutbox(ctx *context.APIContext) {
+ // swagger:operation GET /activitypub/user/{username}/outbox activitypub activitypubPersonOutbox
+ // ---
+ // summary: Returns the outbox
+ // produces:
+ // - application/activity+json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // - name: reponame
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActivityPub"
+
+ link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
+
+ feed, err := models.GetFeeds(ctx, models.GetFeedsOptions{
+ RequestedUser: ctx.ContextUser,
+ Actor: ctx.ContextUser,
+ IncludePrivate: false,
+ OnlyPerformedBy: true,
+ IncludeDeleted: false,
+ Date: ctx.FormString("date"),
+ })
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Couldn't fetch outbox", err)
+ }
+
+ outbox := ap.OrderedCollectionNew(ap.IRI(link))
+ for _, action := range feed {
+ /*if action.OpType == ExampleType {
+ activity := ap.ExampleNew()
+ outbox.OrderedItems.Append(activity)
+ }*/
+ log.Debug(action.Content)
+ }
+ outbox.TotalItems = uint(len(outbox.OrderedItems))
+
+ binary, err := outbox.MarshalJSON()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ }
+ response(ctx, binary)
+}
+
+// RepoFollowers function
+func RepoFollowers(ctx *context.APIContext) {
+ // swagger:operation GET /activitypub/user/{username}/{reponame}/followers activitypub activitypubRepoFollowers
+ // ---
+ // summary: Returns the followers collection
+ // produces:
+ // - application/activity+json
+ // parameters:
+ // - name: username
+ // in: path
+ // description: username of the user
+ // type: string
+ // required: true
+ // - name: reponame
+ // in: path
+ // description: name of the repository
+ // type: string
+ // required: true
+ // responses:
+ // "200":
+ // "$ref": "#/responses/ActivityPub"
+
+ link := strings.TrimSuffix(setting.AppURL, "/") + strings.TrimSuffix(ctx.Req.URL.EscapedPath(), "/")
+
+ users, err := user_model.GetUserFollowers(ctx.ContextUser, utils.GetListOptions(ctx))
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "GetUserFollowers", err)
+ return
+ }
+
+ followers := ap.OrderedCollectionNew(ap.IRI(link))
+ followers.TotalItems = uint(len(users))
+
+ for _, user := range users {
+ person := ap.PersonNew(ap.IRI(user.Website))
+ followers.OrderedItems.Append(person)
+ }
+
+ binary, err := followers.MarshalJSON()
+ if err != nil {
+ ctx.Error(http.StatusInternalServerError, "Serialize", err)
+ }
+ response(ctx, binary)
+}
diff --git a/routers/api/v1/api.go b/routers/api/v1/api.go
index ca2837f49..2e976e966 100644
--- a/routers/api/v1/api.go
+++ b/routers/api/v1/api.go
@@ -651,6 +651,13 @@ func Routes() *web.Route {
m.Get("/outbox", activitypub.PersonOutbox)
m.Get("/following", activitypub.PersonFollowing)
m.Get("/followers", activitypub.PersonFollowers)
+ m.Get("/liked", activitypub.PersonLiked)
+ m.Group("/{reponame}", func() {
+ m.Get("", activitypub.Repo)
+ m.Post("/inbox", activitypub.ReqHTTPSignature(), activitypub.RepoInbox)
+ m.Get("/outbox", activitypub.RepoOutbox)
+ m.Get("/followers", activitypub.RepoFollowers)
+ }, repoAssignment())
}, context_service.UserAssignmentAPI())
})
}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl
index beb405db1..9994df78b 100644
--- a/templates/swagger/v1_json.tmpl
+++ b/templates/swagger/v1_json.tmpl
@@ -57,8 +57,8 @@
"tags": [
"activitypub"
],
- "summary": "Returns the followers collection",
- "operationId": "activitypubPersonFollowers",
+ "summary": "Returns the Liked Collection",
+ "operationId": "activitypubPersonLiked",
"parameters": [
{
"type": "string",
@@ -83,7 +83,7 @@
"tags": [
"activitypub"
],
- "summary": "Returns the following collection",
+ "summary": "Returns the Following Collection",
"operationId": "activitypubPersonFollowing",
"parameters": [
{
@@ -144,6 +144,79 @@
"name": "username",
"in": "path",
"required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repository",
+ "name": "reponame",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/ActivityPub"
+ }
+ }
+ }
+ },
+ "/activitypub/user/{username}/{reponame}": {
+ "get": {
+ "produces": [
+ "application/activity+json"
+ ],
+ "tags": [
+ "activitypub"
+ ],
+ "summary": "Returns the repository",
+ "operationId": "activitypubRepo",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "username of the user",
+ "name": "username",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repository",
+ "name": "reponame",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "200": {
+ "$ref": "#/responses/ActivityPub"
+ }
+ }
+ }
+ },
+ "/activitypub/user/{username}/{reponame}/followers": {
+ "get": {
+ "produces": [
+ "application/activity+json"
+ ],
+ "tags": [
+ "activitypub"
+ ],
+ "summary": "Returns the followers collection",
+ "operationId": "activitypubRepoFollowers",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "username of the user",
+ "name": "username",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repository",
+ "name": "reponame",
+ "in": "path",
+ "required": true
}
],
"responses": {
@@ -153,6 +226,39 @@
}
}
},
+ "/activitypub/user/{username}/{reponame}/inbox": {
+ "post": {
+ "produces": [
+ "application/activity+json"
+ ],
+ "tags": [
+ "activitypub"
+ ],
+ "summary": "Send to the inbox",
+ "operationId": "activitypubRepoInbox",
+ "parameters": [
+ {
+ "type": "string",
+ "description": "username of the user",
+ "name": "username",
+ "in": "path",
+ "required": true
+ },
+ {
+ "type": "string",
+ "description": "name of the repository",
+ "name": "reponame",
+ "in": "path",
+ "required": true
+ }
+ ],
+ "responses": {
+ "204": {
+ "$ref": "#/responses/empty"
+ }
+ }
+ }
+ },
"/admin/cron": {
"get": {
"produces": [