Merge pull request #388 from CanineHQ/celina__token_interface

API Tokens
This commit is contained in:
Chris Zhu
2025-12-16 13:54:04 +09:00
committed by GitHub
34 changed files with 804 additions and 59 deletions

67
Gemfile
View File

@@ -35,6 +35,7 @@ group :development, :test do
gem "simplecov", require: false
gem "pry", "~> 0.15.2"
gem "rspec-rails", "~> 8.0"
gem 'rswag-specs'
gem "factory_bot_rails"
gem "webmock"
@@ -59,58 +60,44 @@ group :test do
gem 'shoulda-matchers', '~> 6.5'
end
gem "actioncable-enhanced-postgresql-adapter", "~> 1.0"
gem "annotate", "~> 3.2"
gem "avo", "~> 3.25"
gem "cron2english", "~> 0.1.7"
gem "cssbundling-rails"
gem "devise", "~> 4.9"
gem "friendly_id", "~> 5.4"
gem "jsbundling-rails"
gem "name_of_person", github: "basecamp/name_of_person"
gem "noticed", "~> 2.9"
gem "omniauth-github", "~> 2.0"
gem "pretender", "~> 0.6.0"
gem "pundit", "~> 2.5"
gem "good_job", "~> 4.12"
gem "sitemap_generator", "~> 6.1"
gem "responders", github: "heartcombo/responders", branch: "main"
gem "dotenv", "~> 3.1"
gem "friendly_id", "~> 5.4"
gem "good_job", "~> 4.12"
gem "httparty", "~> 0.23.2"
gem "image_processing", "~> 1.13"
gem "jsbundling-rails"
gem "k8s-ruby", "~> 0.17.2"
gem "kubeclient", "~> 4.12"
gem "light-service", "~> 0.20.0"
gem "name_of_person", github: "basecamp/name_of_person"
gem "noticed", "~> 2.9"
gem "octokit", "~> 10.0"
gem "omniauth-digitalocean", "~> 0.3.2"
gem "pagy", "~> 9.4"
gem "oj", "~> 3.16"
gem "omniauth", "~> 2.1"
gem "omniauth-rails_csrf_protection", "~> 1.0"
gem "omniauth-github", "~> 2.0"
gem "omniauth_openid_connect", "~> 0.8"
gem "annotate", "~> 3.2"
gem "rack", "~> 3.1"
gem "tailwindcss-rails", "~> 2.7"
gem "httparty", "~> 0.23.2"
gem "redcarpet", "~> 3.6"
gem "rubyzip", "~> 3.2"
gem "sassc-rails", "~> 2.1"
gem "cron2english", "~> 0.1.7"
gem "avo", "~> 3.25"
gem "sentry-ruby", "~> 5.28"
gem "sentry-rails", "~> 5.27"
gem "sys-proctable", "~> 1.3"
gem "omniauth-gitlab", "~> 4.1"
gem "actioncable-enhanced-postgresql-adapter", "~> 1.0"
gem "omniauth-rails_csrf_protection", "~> 1.0"
gem "pagy", "~> 9.4"
gem "pretender", "~> 0.6.0"
gem "pundit", "~> 2.5"
gem "rack", "~> 3.1"
gem "redcarpet", "~> 3.6"
gem 'rswag-api', '~> 2.10', '>= 2.10.1'
gem "responders", github: "heartcombo/responders", branch: "main"
gem "rubyzip", "~> 3.2"
gem "sassc-rails", "~> 2.1"
gem "sentry-rails", "~> 5.27"
gem "sentry-ruby", "~> 5.28"
gem "sitemap_generator", "~> 6.1"
gem "sys-proctable", "~> 1.3"
gem "tailwindcss-rails", "~> 2.7"
gem 'flipper', '~> 1.2.2'
gem 'flipper-active_record', '~> 1.2.2'
gem 'flipper-ui', '~> 1.2.2'

View File

@@ -298,6 +298,9 @@ GEM
bindata
faraday (~> 2.0)
faraday-follow_redirects
json-schema (5.2.2)
addressable (~> 2.8)
bigdecimal (~> 3.1)
jsonpath (1.1.5)
multi_json
jwt (2.9.1)
@@ -399,10 +402,6 @@ GEM
logger
rack (>= 2.2.3)
rack-protection
omniauth-digitalocean (0.3.2)
multi_json (~> 1.15)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.0)
omniauth-github (2.0.1)
omniauth (~> 2.0)
omniauth-oauth2 (~> 1.8)
@@ -546,6 +545,14 @@ GEM
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.4)
rswag-api (2.16.0)
activesupport (>= 5.2, < 8.1)
railties (>= 5.2, < 8.1)
rswag-specs (2.16.0)
activesupport (>= 5.2, < 8.1)
json-schema (>= 2.2, < 6.0)
railties (>= 5.2, < 8.1)
rspec-core (>= 2.14)
rubocop (1.79.2)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -733,7 +740,6 @@ DEPENDENCIES
octokit (~> 10.0)
oj (~> 3.16)
omniauth (~> 2.1)
omniauth-digitalocean (~> 0.3.2)
omniauth-github (~> 2.0)
omniauth-gitlab (~> 4.1)
omniauth-rails_csrf_protection (~> 1.0)
@@ -749,6 +755,8 @@ DEPENDENCIES
redcarpet (~> 3.6)
responders!
rspec-rails (~> 8.0)
rswag-api (~> 2.10, >= 2.10.1)
rswag-specs
rubocop-rails-omakase
rubyzip (~> 3.2)
sassc-rails (~> 2.1)

View File

@@ -11,7 +11,6 @@ class Projects::DeployLatestCommit
project = context.project
current_user = context.current_user || project.account.owner
if project.git?
project_credential_provider = project.project_credential_provider
client = Git::Client.from_project(project)
commit = client.commits(project.branch).first
build = project.builds.create!(

View File

@@ -0,0 +1,28 @@
class ApiTokensController < ApplicationController
def new
@api_token = ApiToken.new(user: current_user)
end
def create
if ApiToken.create(api_token_params.merge(user: current_user))
redirect_to api_tokens_path, notice: "API token saved"
else
render "new", status: :unprocessable_entity
end
end
def destroy
@api_token = current_user.api_tokens.find(params[:id])
if @api_token.destroy
redirect_to api_tokens_path, notice: "API token deleted"
else
redirect_to api_tokens_path, alert: "Failed to delete API token"
end
end
private
def api_token_params
params.require(:api_token).permit(:name, :expires_at)
end
end

View File

@@ -15,6 +15,24 @@ class ApplicationController < ActionController::Base
rescue_from ActiveRecord::RecordNotFound, with: :record_not_found
rescue_from Portainer::Client::MissingCredentialError, with: :missing_portainer_credential
def authenticate_user!(opts = {})
if request.headers["X-API-Key"].present?
authenticate_with_api_token!
else
super
end
end
def authenticate_with_api_token!
token = request.headers["X-API-Key"]
api_token = ApiToken.find_by(access_token: token)
if api_token.present?
@current_user = api_token.user
else
render json: { error: "Invalid API token" }, status: :unauthorized
end
end
protected
def current_account
return nil unless user_signed_in?

View File

@@ -27,10 +27,14 @@ class Projects::DeploymentsController < Projects::BaseController
current_user:,
skip_build: params[:skip_build] == "true"
)
if result.success?
redirect_to @project, notice: "Deploying project #{@project.name}. <a class='underline' href='#{project_deployment_path(@project, result.build)}'>Follow deployment</a>".html_safe
else
redirect_to @project, alert: "Failed to deploy project"
respond_to do |format|
if result.success?
format.html { redirect_to @project, notice: "Deploying project #{@project.name}. <a class='underline' href='#{project_deployment_path(@project, result.build)}'>Follow deployment</a>".html_safe }
format.json { render json: { message: "Deploying project #{@project.name}." }, status: :ok }
else
format.html { redirect_to @project, alert: "Failed to deploy project" }
format.json { render json: { message: "Failed to deploy project" }, status: :unprocessable_entity }
end
end
end

View File

@@ -16,14 +16,19 @@ class ProjectsController < ApplicationController
# Uncomment to authorize with Pundit
# authorize @projects
#
end
def restart
result = Projects::Restart.execute(connection: K8::Connection.new(@project, current_user))
if result.success?
redirect_to project_url(@project), notice: "All services have been restarted"
else
redirect_to project_url(@project), alert: "Failed to restart all services"
respond_to do |format|
if result.success?
format.html { redirect_to project_url(@project), notice: "All services have been restarted" }
format.json { render json: { message: "All services have been restarted" }, status: :ok }
else
format.html { redirect_to project_url(@project), alert: "Failed to restart all services" }
format.json { render json: { message: "Failed to restart all services" }, status: :unprocessable_entity }
end
end
end

View File

@@ -62,4 +62,11 @@ class StaticController < ApplicationController
def calculator
@prices = JSON.parse(File.read(File.join(Rails.root, 'public', 'resources', 'prices.json')))
end
def docs
end
def swagger
render plain: File.read(Rails.root.join('swagger', 'v1', 'swagger.yaml')), layout: false
end
end

View File

@@ -0,0 +1,30 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["select", "dateInput"]
connect() {
this.updateExpiryDate()
}
updateExpiryDate() {
const selectedOption = this.selectTarget.options[this.selectTarget.selectedIndex]
const expiryValue = selectedOption.dataset.expiryValue
if (expiryValue === "custom") {
this.dateInputTarget.classList.remove("hidden")
} else {
this.dateInputTarget.classList.add("hidden")
}
if (!expiryValue || expiryValue === "custom") {
this.setDateValue("");
} else {
const date = new Date(expiryValue);
this.setDateValue(date.toISOString().slice(0, 10));
}
}
setDateValue(value) {
this.dateInputTarget.querySelector("input").value = value;
}
}

39
app/models/api_token.rb Normal file
View File

@@ -0,0 +1,39 @@
# == Schema Information
#
# Table name: api_tokens
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime
# last_used_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_api_tokens_on_access_token (access_token) UNIQUE
# index_api_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class ApiToken < ApplicationRecord
belongs_to :user
validates :user, :access_token, presence: true
validates :access_token, uniqueness: { scope: :user_id }
before_validation :generate_token, if: :new_record?
def generate_token
self.access_token = SecureRandom.hex(16)
end
def expired?
return false if expires_at.nil?
expires_at < Time.zone.now
end
end

View File

@@ -42,6 +42,7 @@ class User < ApplicationRecord
has_many :projects, through: :accounts
has_many :add_ons, through: :accounts
has_many :services, through: :accounts
has_many :api_tokens, dependent: :destroy
attr_readonly :admin
# has_many :notifications, as: :recipient, dependent: :destroy, class_name: "Noticed::Notification"

View File

@@ -0,0 +1,43 @@
<%
seven_days = 7.days.from_now
thirty_days = 30.days.from_now
sixty_days = 60.days.from_now
ninety_days = 90.days.from_now
%>
<div data-controller="expiry-date" class="flex flex-row gap-6">
<div class="form-control w-full max-w-sm">
<label class="label">
<span class="label-text">Expiration</span>
</label>
<%= form.select :expiry_preset,
[
["7 days (#{seven_days.strftime('%b %d, %Y')})", "7_days", { data: { expiry_value: seven_days.strftime('%Y-%m-%dT%H:%M') } }],
["30 days (#{thirty_days.strftime('%b %d, %Y')})", "30_days", { data: { expiry_value: thirty_days.strftime('%Y-%m-%dT%H:%M') } }],
["60 days (#{sixty_days.strftime('%b %d, %Y')})", "60_days", { data: { expiry_value: sixty_days.strftime('%Y-%m-%dT%H:%M') } }],
["90 days (#{ninety_days.strftime('%b %d, %Y')})", "90_days", { data: { expiry_value: ninety_days.strftime('%Y-%m-%dT%H:%M') } }],
["No Expiration", "never", { data: { expiry_value: "" } }],
["Custom", "custom", { data: { expiry_value: "custom" } }]
],
{ selected: "7_days" },
{
class: "select select-bordered",
data: {
expiry_date_target: "select",
action: "change->expiry-date#updateExpiryDate"
}
}
%>
</div>
<div class="form-control w-full max-w-sm" data-expiry-date-target="dateInput">
<label class="label">
<span class="label-text">Select date</span>
</label>
<%= form.date_field :expires_at,
value: seven_days.strftime('%Y-%m-%d'),
class: "input input-bordered",
data: { "expiry-date-target": "dateInput" }
%>
</div>
</div>

View File

@@ -0,0 +1,13 @@
<%= form_with model: api_token do |form| %>
<%= render "shared/error_messages", resource: form.object %>
<div class="form-group">
<%= form.label :name %>
<%= form.text_field :name, class: "input input-bordered w-full max-w-sm", required: true, placeholder: "my-api-token" %>
</div>
<%= render "api_tokens/expiry_date_field", form: form %>
<div class="form-footer">
<%= form.submit "Save", class: "btn btn-primary" %>
<%= link_to "Cancel", api_tokens_path, class: "btn btn-outline" %>
</div>
<% end %>

View File

@@ -0,0 +1,42 @@
<div class="overflow-x-auto mb-4">
<table class="table">
<!-- head -->
<thead>
<tr>
<th>Name</th>
<th>API Token</th>
<th>Last Used</th>
<th>Expires At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<% current_user.api_tokens.order(created_at: :desc).each do |api_token| %>
<tr>
<td><%= api_token.name %></td>
<td>
<% if api_token.created_at == api_token.updated_at %>
<div class="cursor-pointer flex items-center gap-2" data-controller="clipboard" data-clipboard-text="<%= api_token.access_token %>">
<%= api_token.access_token %>
<iconify-icon icon="lucide:clipboard" height="16"></iconify-icon>
</div>
<span class="text-gray-500">(You can only see this once)</span>
<% api_token.touch(:updated_at) %>
<% else %>
<%= api_token.access_token.first(4) %><%= "*" * [8, api_token.access_token.length - 16].min %><%= api_token.access_token.last(4) %>
<% end %>
</td>
<td>
<%= (api_token.last_used_at.strftime("%b %d, %Y") if api_token.last_used_at) || "Never" %>
</td>
<td>
<%= (api_token.expires_at.strftime("%b %d, %Y") if api_token.expires_at) || "Never" %>
</td>
<td>
<%= link_to "Delete", api_token, method: :delete, class: "btn btn-error btn-sm" %>
</td>
</tr>
<% end %>
</tbody>
</table>
</div>

View File

@@ -0,0 +1,10 @@
<%= settings_layout do %>
<h2 class="text-2xl font-bold">API Tokens</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= turbo_frame_tag "api_token" do %>
<%= render "api_tokens/index" %>
<div class="flex items-center gap-2">
<%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
</div>
<% end %>
<% end %>

View File

@@ -0,0 +1,8 @@
<%= settings_layout do %>
<%= turbo_frame_tag "api_token" do %>
<div class="font-lg font-bold my-4">
Add API Token
</div>
<%= render "api_tokens/form", api_token: @api_token %>
<% end %>
<% end %>

View File

@@ -5,10 +5,22 @@
<%= turbo_frame_tag "provider" do %>
<%= render "providers/index" %>
<div class="flex items-center gap-2">
<%= link_to "+ Github Credentials", new_provider_path(provider_type: Provider::GITHUB_PROVIDER), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Gitlab Credentials", new_provider_path(provider_type: Provider::GITLAB_PROVIDER), class: "btn btn-primary btn-sm" %>
<%= link_to "+ Container Registry Credentials", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER), class: "btn btn-primary btn-sm" %>
<div class="dropdown dropdown-end">
<div tabindex="0" role="button" class="btn btn-primary btn-sm">
+ New Credential
<iconify-icon icon="lucide:chevron-down"></iconify-icon>
</div>
<ul tabindex="0" class="dropdown-content menu bg-base-200 rounded-box z-[1] w-60 p-2 shadow">
<li>
<%= link_to "Github", new_provider_path(provider_type: Provider::GITHUB_PROVIDER) %>
<li>
<%= link_to "Gitlab", new_provider_path(provider_type: Provider::GITLAB_PROVIDER) %>
</li>
<li>
<%= link_to "Container Registry", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER) %>
</li>
</ul>
</div>
<% if current_account.stack_manager&.stack&.provides_registries? %>
<%= button_to sync_registries_stack_manager_path, class: "btn btn-outline btn-sm" do %>
<iconify-icon icon="lucide:refresh-ccw" height="16"></iconify-icon>
@@ -18,8 +30,18 @@
</div>
<% end %>
<div class="mt-16">
<h2 class="text-2xl font-bold">API Tokens</h2>
<hr class="mt-3 mb-4 border-t border-base-300" />
<%= turbo_frame_tag "api_token" do %>
<%= render "api_tokens/index" %>
<div class="flex items-center gap-2">
<%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
</div>
<% end %>
</div>
<% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %>
<hr class="my-6 border-t border-base-300" />
<%= render "providers/portainer_token" %>
<% end %>
<% end %>
<% end %>

View File

@@ -0,0 +1,10 @@
<html>
<head>
<title>Canine API Documentation</title>
</head>
<body>
<redoc spec-url='<%= swagger_path %>'
></redoc>
<script src="https://cdn.redoc.ly/redoc/latest/bundles/redoc.standalone.js"> </script>
</body>
</html>

View File

@@ -58,6 +58,7 @@ Rails.application.configure do
# Raises error for missing translations.
# config.i18n.raise_on_missing_translations = true
config.hosts = nil
# Annotate rendered view with file names.
# config.action_view.annotate_rendered_view_with_filenames = true

View File

@@ -0,0 +1,13 @@
Rswag::Api.configure do |c|
# Specify a root folder where Swagger JSON files are located
# This is used by the Swagger middleware to serve requests for API descriptions
# NOTE: If you're using rswag-specs to generate Swagger, you'll need to ensure
# that it's configured to generate files in the same folder
c.openapi_root = Rails.root.to_s + '/swagger'
# Inject a lambda function to alter the returned Swagger prior to serialization
# The function will have access to the rack env for the current request
# For example, you could leverage this to dynamically assign the "host" property
#
# c.swagger_filter = lambda { |swagger, env| swagger['host'] = env['HTTP_HOST'] }
end

View File

@@ -77,6 +77,7 @@ Rails.application.routes.draw do
end
resources :providers, only: %i[index new create destroy]
resources :api_tokens, only: %i[index new create destroy]
resource :portainer_token, only: %i[update destroy], controller: 'providers/portainer_tokens'
resources :projects do
member do
@@ -154,6 +155,9 @@ Rails.application.routes.draw do
get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
get "async_render" => "async_renderer#async_render"
get "/api-docs", to: "static#docs"
get "/swagger", to: "static#swagger"
get "/calculator", to: "static#calculator"
# Public marketing homepage
if Rails.application.config.local_mode

View File

@@ -0,0 +1,13 @@
class CreateApiTokens < ActiveRecord::Migration[7.2]
def change
create_table :api_tokens do |t|
t.references :user, null: false, foreign_key: true
t.string :name, null: false
t.string :access_token, null: false
t.datetime :last_used_at
t.datetime :expires_at
t.timestamps
end
add_index :api_tokens, :access_token, unique: true
end
end

12
db/schema.rb generated
View File

@@ -86,6 +86,17 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_12_215414) do
t.datetime "updated_at", null: false
end
create_table "api_tokens", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "access_token", null: false
t.datetime "last_used_at"
t.datetime "expires_at"
t.jsonb "scopes", default: [], null: false, array: true
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id"], name: "index_api_tokens_on_user_id"
end
create_table "build_clouds", force: :cascade do |t|
t.bigint "cluster_id", null: false
t.string "namespace", default: "canine-k8s-builder", null: false
@@ -608,6 +619,7 @@ ActiveRecord::Schema[7.2].define(version: 2025_12_12_215414) do
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "add_ons", "clusters"
add_foreign_key "api_tokens", "users"
add_foreign_key "build_clouds", "clusters"
add_foreign_key "build_configurations", "build_clouds"
add_foreign_key "build_configurations", "projects"

View File

@@ -0,0 +1,29 @@
# == Schema Information
#
# Table name: api_tokens
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime
# last_used_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_api_tokens_on_access_token (access_token) UNIQUE
# index_api_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
FactoryBot.define do
factory :api_token do
user
expires_at { nil }
last_used_at { nil }
end
end

View File

@@ -0,0 +1,44 @@
# frozen_string_literal: true
SwaggerSchemas::PROJECT = {
type: :object,
required: %w[id name repository_url branch cluster_id subfolder updated_at created_at],
properties: {
id: {
type: :integer,
example: '1'
},
name: {
type: :string,
example: 'example-project'
},
repository_url: {
type: :string,
example: 'https://github.com/example/example-project'
},
branch: {
type: :string,
example: 'main'
},
cluster_id: {
type: :integer,
example: '1'
},
subfolder: {
type: :string,
example: 'example-subfolder'
},
updated_at: {
type: :string,
example: '2021-01-01T00:00:00Z'
},
created_at: {
type: :string,
example: '2021-01-01T00:00:00Z'
},
url: {
type: :string,
example: 'https://example.com/projects/1'
}
}
}.freeze

View File

@@ -0,0 +1,6 @@
SwaggerSchemas::PROJECTS = {
type: :array,
items: {
'$ref' => '#/components/schemas/project'
}
}.freeze

View File

@@ -0,0 +1,19 @@
# frozen_string_literal: true
module SwaggerSchemas
def self.schemas
constants.to_h do |constant|
[ constant.to_s.downcase, const_get(constant) ]
end
end
def self.validate!
schemas.each do |schema_name, schema|
next unless const_defined?(schema_name.upcase)
end
end
end
Dir["#{File.dirname(__FILE__)}/models/**/*.rb"].each do |file|
require file
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
require 'rails_helper'
require './spec/integration/api/swagger_schemas'
RSpec.describe SwaggerSchemas do
it 'matches' do
expect { described_class.validate! }.not_to raise_error
end
end

View File

@@ -0,0 +1,31 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe Projects::DeploymentsController, :swagger, type: :request do
include ApplicationHelper
let(:api_token) { create :api_token, user: }
let(:'X-API-Key') { api_token.access_token }
let(:account) { create :account }
let(:user) { create :user }
let!(:account_user) { create :account_user, account:, user: }
let!(:cluster) { create :cluster, account: }
let(:project) { create :project, :container_registry, cluster:, account: }
path '/projects/{project_id}/deployments/deploy' do
let(:project_id) { project.id }
post('Deploy Project') do
tags 'Deployments'
operationId 'deployProject'
consumes 'application/json'
produces 'application/json'
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
parameter name: :project_id, in: :path, type: :integer, description: 'Project ID'
response(200, 'successful') do
run_test!
end
end
end
end

View File

@@ -0,0 +1,46 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe ProjectsController, :swagger, type: :request do
include ApplicationHelper
let(:api_token) { create :api_token }
let(:'X-API-Key') { api_token.access_token }
let(:account) { create :account }
let(:project) { create :project, account: }
before do
api_token.user.accounts << account
end
path '/projects' do
get('List Projects') do
tags 'Projects'
operationId 'listProjects'
produces 'application/json'
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
response(200, 'successful') do
schema '$ref' => '#/components/schemas/projects'
run_test!
end
end
end
path '/projects/{id}/restart' do
let(:id) { project.id }
post('Restart Project') do
tags 'Projects'
operationId 'restartProject'
consumes 'application/json'
produces 'application/json'
security [ x_api_key: [] ]
parameter name: :id, in: :path, type: :integer, description: 'Project ID'
parameter name: 'X-API-Key', in: :header, type: :string, description: 'API Key'
response(200, 'successful') do
run_test!
end
end
end
end

View File

@@ -0,0 +1,59 @@
# == Schema Information
#
# Table name: api_tokens
#
# id :bigint not null, primary key
# access_token :string not null
# expires_at :datetime
# last_used_at :datetime
# name :string not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_api_tokens_on_access_token (access_token) UNIQUE
# index_api_tokens_on_user_id (user_id)
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
require 'rails_helper'
RSpec.describe ApiToken, type: :model do
context "#generate_token" do
it "generates a random access token on create" do
api_token = create(:api_token)
expect(api_token.access_token).not_to be_nil
end
end
context "#expired?" do
let(:api_token) { create(:api_token, expires_at:) }
let(:expires_at) { nil }
context "when expires_at is nil" do
it "returns false" do
expect(api_token.expired?).to be_falsey
end
end
context "when expires_at is in the future" do
let(:expires_at) { 1.day.from_now }
it "returns false" do
expect(api_token.expired?).to be_falsey
end
end
context "when expires_at is in the past" do
let(:expires_at) { 1.day.ago }
it "returns true" do
expect(api_token.expired?).to be_truthy
end
end
end
end

View File

@@ -60,6 +60,8 @@ RSpec.configure do |config|
# instead of true.
config.use_transactional_fixtures = false
config.rswag_dry_run = false
# You can uncomment this line to turn off ActiveRecord support entirely.
# config.use_active_record = false

57
spec/swagger_helper.rb Normal file
View File

@@ -0,0 +1,57 @@
# frozen_string_literal: true
require 'rails_helper'
require 'integration/api/swagger_schemas'
servers = [
{
url: 'https://canine.sh',
description: 'Production'
}
]
RSpec.configure do |config|
# Specify a root folder where Swagger JSON files are generated
# NOTE: If you're using the rswag-api to serve API descriptions, you'll need
# to ensure that it's configured to serve Swagger from the same folder
config.openapi_root = Rails.root.join('swagger').to_s
# Define one or more Swagger documents and provide global metadata for each one
# When you run the 'rswag:specs:swaggerize' rake task, the complete Swagger will
# be generated at the provided relative path under swagger_root
# By default, the operations defined in spec files are added to the first
# document below. You can override this behavior by adding a swagger_doc tag to the
# the root example_group in your specs, e.g. describe '...', swagger_doc: 'v2/swagger.json'
config.openapi_specs = {
'v1/swagger.yaml' => {
openapi: '3.0.1',
info: {
title: 'Canine API V1',
version: 'v1',
description: "Canine API"
},
components: {
schemas: SwaggerSchemas.schemas,
securitySchemes: {
x_api_key: {
type: :apiKey,
name: 'X-API-Key',
in: :header
}
}
},
security: [
{ x_api_key: [] }
],
paths: {},
servers:
}
}
# Specify the format of the output Swagger file when running 'rswag:specs:swaggerize'.
# The swagger_docs configuration option has the filename including format in
# the key, this may want to be changed to avoid putting yaml in json files.
# Defaults to json. Accepts ':json' and ':yaml'.
config.openapi_format = :yaml
config.swagger_strict_schema_validation = true
end

125
swagger/v1/swagger.yaml Normal file
View File

@@ -0,0 +1,125 @@
---
openapi: 3.0.1
info:
title: Canine API V1
version: v1
description: Canine API
components:
schemas:
project:
type: object
required:
- id
- name
- repository_url
- branch
- cluster_id
- subfolder
- updated_at
- created_at
properties:
id:
type: integer
example: '1'
name:
type: string
example: example-project
repository_url:
type: string
example: https://github.com/example/example-project
branch:
type: string
example: main
cluster_id:
type: integer
example: '1'
subfolder:
type: string
example: example-subfolder
updated_at:
type: string
example: '2021-01-01T00:00:00Z'
created_at:
type: string
example: '2021-01-01T00:00:00Z'
url:
type: string
example: https://example.com/projects/1
projects:
type: array
items:
"$ref": "#/components/schemas/project"
securitySchemes:
x_api_key:
type: apiKey
name: X-API-Key
in: header
security:
- x_api_key: []
paths:
"/projects/{project_id}/deployments/deploy":
post:
summary: Deploy Project
tags:
- Deployments
operationId: deployProject
parameters:
- name: X-API-Key
in: header
description: API Key
schema:
type: string
- name: project_id
in: path
description: Project ID
required: true
schema:
type: integer
responses:
'200':
description: successful
"/projects":
get:
summary: List Projects
tags:
- Projects
operationId: listProjects
parameters:
- name: X-API-Key
in: header
description: API Key
schema:
type: string
responses:
'200':
description: successful
content:
application/json:
schema:
"$ref": "#/components/schemas/projects"
"/projects/{id}/restart":
post:
summary: Restart Project
tags:
- Projects
operationId: restartProject
security:
- x_api_key: []
parameters:
- name: id
in: path
description: Project ID
required: true
schema:
type: integer
- name: X-API-Key
in: header
description: API Key
schema:
type: string
responses:
'200':
description: successful
servers:
- url: https://canine.sh
description: Production