Commit 8fe880dc authored by Francisco Javier López's avatar Francisco Javier López Committed by Douwe Maan

Projects and groups badges API

parent 49f72d06
class Badge < ActiveRecord::Base
# This structure sets the placeholders that the urls
# can have. This hash also sets which action to ask when
# the placeholder is found.
PLACEHOLDERS = {
'project_path' => :full_path,
'project_id' => :id,
'default_branch' => :default_branch,
'commit_sha' => ->(project) { project.commit&.sha }
}.freeze
# This regex is built dynamically using the keys from the PLACEHOLDER struct.
# So, we can easily add new placeholder just by modifying the PLACEHOLDER hash.
# This regex will build the new PLACEHOLDER_REGEX with the new information
PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze
default_scope { order_created_at_asc }
scope :order_created_at_asc, -> { reorder(created_at: :asc) }
validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX }
validates :type, presence: true
def rendered_link_url(project = nil)
build_rendered_url(link_url, project)
end
def rendered_image_url(project = nil)
build_rendered_url(image_url, project)
end
private
def build_rendered_url(url, project = nil)
return url unless valid? && project
Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg|
replace_placeholder_action(PLACEHOLDERS[arg], project)
end
end
# The action param represents the :symbol or Proc to call in order
# to retrieve the return value from the project.
# This method checks if it is a Proc and use the call method, and if it is
# a symbol just send the action
def replace_placeholder_action(action, project)
return unless project
action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend
end
end
class GroupBadge < Badge
belongs_to :group
validates :group, presence: true
end
class ProjectBadge < Badge
belongs_to :project
validates :project, presence: true
def rendered_link_url(project = nil)
project ||= self.project
super
end
def rendered_image_url(project = nil)
project ||= self.project
super
end
end
......@@ -31,6 +31,8 @@ class Group < Namespace
has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :badges, class_name: 'GroupBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
validate :visibility_level_allowed_by_projects
......
......@@ -221,6 +221,8 @@ class Project < ActiveRecord::Base
has_one :auto_devops, class_name: 'ProjectAutoDevops'
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
accepts_nested_attributes_for :import_data
......@@ -1766,6 +1768,17 @@ class Project < ActiveRecord::Base
.set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION)
end
def badges
return project_badges unless group
group_badges_rel = GroupBadge.where(group: group.self_and_ancestors)
union = Gitlab::SQL::Union.new([project_badges.select(:id),
group_badges_rel.select(:id)])
Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection
end
private
def storage
......
module Badges
class BaseService
protected
attr_accessor :params
def initialize(params = {})
@params = params.dup
end
end
end
module Badges
class BuildService < Badges::BaseService
# returns the created badge
def execute(source)
if source.is_a?(Group)
GroupBadge.new(params.merge(group: source))
else
ProjectBadge.new(params.merge(project: source))
end
end
end
end
module Badges
class CreateService < Badges::BaseService
# returns the created badge
def execute(source)
badge = Badges::BuildService.new(params).execute(source)
badge.tap { |b| b.save }
end
end
end
module Badges
class UpdateService < Badges::BaseService
# returns the updated badge
def execute(badge)
if params.present?
badge.update_attributes(params)
end
badge
end
end
end
# UrlValidator
#
# Custom validator for URLs.
#
# By default, only URLs for the HTTP(S) protocols will be considered valid.
# Provide a `:protocols` option to configure accepted protocols.
#
# Also, this validator can help you validate urls with placeholders inside.
# Usually, if you have a url like 'http://www.example.com/%{project_path}' the
# URI parser will reject that URL format. Provide a `:placeholder_regex` option
# to configure accepted placeholders.
#
# Example:
#
# class User < ActiveRecord::Base
# validates :personal_url, url: true
#
# validates :ftp_url, url: { protocols: %w(ftp) }
#
# validates :git_url, url: { protocols: %w(http https ssh git) }
#
# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ }
# end
#
class UrlPlaceholderValidator < UrlValidator
def validate_each(record, attribute, value)
placeholder_regex = self.options[:placeholder_regex]
value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value
super(record, attribute, value)
end
end
......@@ -23,6 +23,12 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) }
.project-badges
- @project.badges.each do |badge|
- badge_link_url = badge.rendered_link_url(@project)
%a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' }
%img{ src: badge.rendered_image_url(@project), alt: badge_link_url }
.project-repo-buttons
.count-buttons
= render 'projects/buttons/star'
......
---
title: Implemented badge API endpoints
merge_request: 17082
author:
type: added
......@@ -26,6 +26,7 @@ module Gitlab
# This is a nice reference article on autoloading/eager loading:
# http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload
config.eager_load_paths.push(*%W[#{config.root}/lib
#{config.root}/app/models/badges
#{config.root}/app/models/hooks
#{config.root}/app/models/members
#{config.root}/app/models/project_services
......
class CreateBadges < ActiveRecord::Migration
DOWNTIME = false
def change
create_table :badges do |t|
t.string :link_url, null: false
t.string :image_url, null: false
t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: true
t.integer :group_id, index: true, null: true
t.string :type, null: false
t.timestamps_with_timezone null: false
end
add_foreign_key :badges, :namespaces, column: :group_id, on_delete: :cascade
end
end
......@@ -183,6 +183,19 @@ ActiveRecord::Schema.define(version: 20180304204842) do
add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree
add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree
create_table "badges", force: :cascade do |t|
t.string "link_url", null: false
t.string "image_url", null: false
t.integer "project_id"
t.integer "group_id"
t.string "type", null: false
t.datetime_with_timezone "created_at", null: false
t.datetime_with_timezone "updated_at", null: false
end
add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree
add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree
create_table "boards", force: :cascade do |t|
t.integer "project_id", null: false
t.datetime "created_at", null: false
......@@ -1969,6 +1982,8 @@ ActiveRecord::Schema.define(version: 20180304204842) do
add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree
add_index "web_hooks", ["type"], name: "index_web_hooks_on_type", using: :btree
add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade
add_foreign_key "badges", "projects", on_delete: :cascade
add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade
add_foreign_key "chat_teams", "namespaces", on_delete: :cascade
add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade
......
......@@ -24,6 +24,7 @@ following locations:
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
- [Group Access Requests](access_requests.md)
- [Group Badges](group_badges.md)
- [Group Members](members.md)
- [Issues](issues.md)
- [Issue Boards](boards.md)
......@@ -43,6 +44,7 @@ following locations:
- [Pipeline Schedules](pipeline_schedules.md)
- [Projects](projects.md) including setting Webhooks
- [Project Access Requests](access_requests.md)
- [Project Badges](project_badges.md)
- [Project import/export](project_import_export.md)
- [Project Members](members.md)
- [Project Snippets](project_snippets.md)
......
# Group badges API
## Placeholder tokens
Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
- **%{project_path}**: will be replaced by the project path.
- **%{project_id}**: will be replaced by the project id.
- **%{default_branch}**: will be replaced by the project default branch.
- **%{commit_sha}**: will be replaced by the last project's commit sha.
Because these enpoints aren't inside a project's context, the information used to replace the placeholders will be
from the first group's project by creation date. If the group hasn't got any project the original URL with the placeholders will be returned.
## List all badges of a group
Gets a list of a group's badges.
```
GET /groups/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges
```
Example response:
```json
[
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
{
"id": 2,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
]
```
## Get a badge of a group
Gets a badge of a group.
```
GET /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
}
```
## Add a badge to a group
Adds a badge to a group.
```
POST /groups/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link |
| `image_url` | string | yes | URL of the badge image |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/groups/:id/badges
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge1",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge1",
"kind": "group"
}
```
## Edit a badge of a group
Updates a badge of a group.
```
PUT /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
| `link_url` | string | no | URL of the badge link |
| `image_url` | string | no | URL of the badge image |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
}
```
## Remove a badge from a group
Removes a badge from a group.
```
DELETE /groups/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id
```
## Preview a badge from a group
Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
```
GET /groups/:id/badges/render
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link|
| `image_url` | string | yes | URL of the badge image |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
```
Example response:
```json
{
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
}
```
......@@ -525,3 +525,7 @@ And to switch pages add:
```
[ce-15142]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15142
## Group badges
Read more in the [Group Badges](group_badges.md) documentation.
# Project badges API
## Placeholder tokens
Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are:
- **%{project_path}**: will be replaced by the project path.
- **%{project_id}**: will be replaced by the project id.
- **%{default_branch}**: will be replaced by the project default branch.
- **%{commit_sha}**: will be replaced by the last project's commit sha.
## List all badges of a project
Gets a list of a project's badges and its group badges.
```
GET /projects/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges
```
Example response:
```json
[
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
},
{
"id": 2,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "group"
},
]
```
## Get a badge of a project
Gets a badge of a project.
```
GET /projects/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
}
```
## Add a badge to a project
Adds a badge to a project.
```
POST /projects/:id/badges
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project ](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link |
| `image_url` | string | yes | URL of the badge image |
```bash
curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/projects/:id/badges
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge1",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge1",
"kind": "project"
}
```
## Edit a badge of a project
Updates a badge of a project.
```
PUT /projects/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
| `link_url` | string | no | URL of the badge link |
| `image_url` | string | no | URL of the badge image |
```bash
curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
Example response:
```json
{
"id": 1,
"link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master",
"rendered_image_url": "https://shields.io/my/badge",
"kind": "project"
}
```
## Remove a badge from a project
Removes a badge from a project. Only project's badges will be removed by using this endpoint.
```
DELETE /projects/:id/badges/:badge_id
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `badge_id` | integer | yes | The badge ID |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id
```
## Preview a badge from a project
Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation.
```
GET /projects/:id/badges/render
```
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `link_url` | string | yes | URL of the badge link|
| `image_url` | string | yes | URL of the badge image |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge
```
Example response:
```json
{
"link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}",
"image_url": "https://shields.io/my/badge",
"rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master",
"rendered_image_url": "https://shields.io/my/badge",
}
```
......@@ -1340,3 +1340,7 @@ Read more in the [Project import/export](project_import_export.md) documentation
## Project members
Read more in the [Project members](members.md) documentation.
## Project badges
Read more in the [Project Badges](project_badges.md) documentation.
......@@ -108,6 +108,7 @@ module API
mount ::API::AccessRequests
mount ::API::Applications
mount ::API::AwardEmoji
mount ::API::Badges
mount ::API::Boards
mount ::API::Branches
mount ::API::BroadcastMessages
......
module API
class Badges < Grape::API
include PaginationParams
before { authenticate_non_get! }
helpers ::API::Helpers::BadgesHelpers
helpers do
def find_source_if_admin(source_type)
source = find_source(source_type, params[:id])
authorize_admin_source!(source_type, source)
source
end
end
%w[group project].each do |source_type|
params do
requires :id, type: String, desc: "The ID of a #{source_type}"
end
resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do