diff --git a/Gemfile b/Gemfile
index 2ef8b9c7..1a9e6581 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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'
diff --git a/Gemfile.lock b/Gemfile.lock
index e9d60357..56162cb6 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -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)
diff --git a/app/actions/projects/deploy_latest_commit.rb b/app/actions/projects/deploy_latest_commit.rb
index 9800d503..b9a445a3 100644
--- a/app/actions/projects/deploy_latest_commit.rb
+++ b/app/actions/projects/deploy_latest_commit.rb
@@ -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!(
diff --git a/app/controllers/api_tokens_controller.rb b/app/controllers/api_tokens_controller.rb
new file mode 100644
index 00000000..9fef5984
--- /dev/null
+++ b/app/controllers/api_tokens_controller.rb
@@ -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
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 46053350..087e26cb 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -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?
diff --git a/app/controllers/projects/deployments_controller.rb b/app/controllers/projects/deployments_controller.rb
index 739df629..9499bd80 100644
--- a/app/controllers/projects/deployments_controller.rb
+++ b/app/controllers/projects/deployments_controller.rb
@@ -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}. Follow deployment".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}. Follow deployment".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
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 1863da2e..4e780f38 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -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
diff --git a/app/controllers/static_controller.rb b/app/controllers/static_controller.rb
index 45850392..3ac3ae5f 100644
--- a/app/controllers/static_controller.rb
+++ b/app/controllers/static_controller.rb
@@ -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
diff --git a/app/javascript/controllers/expiry_date_controller.js b/app/javascript/controllers/expiry_date_controller.js
new file mode 100644
index 00000000..25cb3aed
--- /dev/null
+++ b/app/javascript/controllers/expiry_date_controller.js
@@ -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;
+ }
+}
diff --git a/app/models/api_token.rb b/app/models/api_token.rb
new file mode 100644
index 00000000..7731ce8a
--- /dev/null
+++ b/app/models/api_token.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index a0a778ce..917ce9c2 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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"
diff --git a/app/views/api_tokens/_expiry_date_field.html.erb b/app/views/api_tokens/_expiry_date_field.html.erb
new file mode 100644
index 00000000..5e198150
--- /dev/null
+++ b/app/views/api_tokens/_expiry_date_field.html.erb
@@ -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
+%>
+
+
+
+
+ <%= 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"
+ }
+ }
+ %>
+
+
+
+
+ <%= form.date_field :expires_at,
+ value: seven_days.strftime('%Y-%m-%d'),
+ class: "input input-bordered",
+ data: { "expiry-date-target": "dateInput" }
+ %>
+
+
diff --git a/app/views/api_tokens/_form.html.erb b/app/views/api_tokens/_form.html.erb
new file mode 100644
index 00000000..9fcd7093
--- /dev/null
+++ b/app/views/api_tokens/_form.html.erb
@@ -0,0 +1,13 @@
+<%= form_with model: api_token do |form| %>
+ <%= render "shared/error_messages", resource: form.object %>
+
+ <%= form.label :name %>
+ <%= form.text_field :name, class: "input input-bordered w-full max-w-sm", required: true, placeholder: "my-api-token" %>
+
+ <%= render "api_tokens/expiry_date_field", form: form %>
+
+
+<% end %>
diff --git a/app/views/api_tokens/_index.html.erb b/app/views/api_tokens/_index.html.erb
new file mode 100644
index 00000000..b041f67d
--- /dev/null
+++ b/app/views/api_tokens/_index.html.erb
@@ -0,0 +1,42 @@
+
+
+
+
+
+ | Name |
+ API Token |
+ Last Used |
+ Expires At |
+ Actions |
+
+
+
+ <% current_user.api_tokens.order(created_at: :desc).each do |api_token| %>
+
+ | <%= api_token.name %> |
+
+ <% if api_token.created_at == api_token.updated_at %>
+
+ <%= api_token.access_token %>
+
+
+ (You can only see this once)
+ <% 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 %>
+ |
+
+ <%= (api_token.last_used_at.strftime("%b %d, %Y") if api_token.last_used_at) || "Never" %>
+ |
+
+ <%= (api_token.expires_at.strftime("%b %d, %Y") if api_token.expires_at) || "Never" %>
+ |
+
+ <%= link_to "Delete", api_token, method: :delete, class: "btn btn-error btn-sm" %>
+ |
+
+ <% end %>
+
+
+
diff --git a/app/views/api_tokens/index.html.erb b/app/views/api_tokens/index.html.erb
new file mode 100644
index 00000000..86800f30
--- /dev/null
+++ b/app/views/api_tokens/index.html.erb
@@ -0,0 +1,10 @@
+<%= settings_layout do %>
+ API Tokens
+
+ <%= turbo_frame_tag "api_token" do %>
+ <%= render "api_tokens/index" %>
+
+ <%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
+
+ <% end %>
+<% end %>
diff --git a/app/views/api_tokens/new.html.erb b/app/views/api_tokens/new.html.erb
new file mode 100644
index 00000000..66d31542
--- /dev/null
+++ b/app/views/api_tokens/new.html.erb
@@ -0,0 +1,8 @@
+<%= settings_layout do %>
+ <%= turbo_frame_tag "api_token" do %>
+
+ Add API Token
+
+ <%= render "api_tokens/form", api_token: @api_token %>
+ <% end %>
+<% end %>
diff --git a/app/views/providers/index.html.erb b/app/views/providers/index.html.erb
index e30d068d..aa2d6623 100644
--- a/app/views/providers/index.html.erb
+++ b/app/views/providers/index.html.erb
@@ -5,10 +5,22 @@
<%= turbo_frame_tag "provider" do %>
<%= render "providers/index" %>
- <%= 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" %>
-
+
+
+ + New Credential
+
+
+
+ -
+ <%= link_to "Github", new_provider_path(provider_type: Provider::GITHUB_PROVIDER) %>
+
-
+ <%= link_to "Gitlab", new_provider_path(provider_type: Provider::GITLAB_PROVIDER) %>
+
+ -
+ <%= link_to "Container Registry", new_provider_path(provider_type: Provider::CUSTOM_REGISTRY_PROVIDER) %>
+
+
+
<% if current_account.stack_manager&.stack&.provides_registries? %>
<%= button_to sync_registries_stack_manager_path, class: "btn btn-outline btn-sm" do %>
@@ -18,8 +30,18 @@
<% end %>
+
+
API Tokens
+
+ <%= turbo_frame_tag "api_token" do %>
+ <%= render "api_tokens/index" %>
+
+ <%= link_to "+ API Token", new_api_token_path, class: "btn btn-primary btn-sm" %>
+
+ <% end %>
+
<% if current_account.stack_manager&.portainer? && current_account.stack_manager.enable_role_based_access_control? %>
<%= render "providers/portainer_token" %>
<% end %>
-<% end %>
\ No newline at end of file
+<% end %>
diff --git a/app/views/static/docs.html.erb b/app/views/static/docs.html.erb
new file mode 100644
index 00000000..13811a5f
--- /dev/null
+++ b/app/views/static/docs.html.erb
@@ -0,0 +1,10 @@
+
+
+ Canine API Documentation
+
+
+
+
+
+
diff --git a/config/environments/test.rb b/config/environments/test.rb
index 816ad487..ee1ba2ab 100644
--- a/config/environments/test.rb
+++ b/config/environments/test.rb
@@ -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
diff --git a/config/initializers/rswag_api.rb b/config/initializers/rswag_api.rb
new file mode 100644
index 00000000..c0196bd2
--- /dev/null
+++ b/config/initializers/rswag_api.rb
@@ -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
diff --git a/config/routes.rb b/config/routes.rb
index ab82cf85..369c886e 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -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
diff --git a/db/migrate/20251022183720_create_api_tokens.rb b/db/migrate/20251022183720_create_api_tokens.rb
new file mode 100644
index 00000000..2dc8685d
--- /dev/null
+++ b/db/migrate/20251022183720_create_api_tokens.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
index 71628ec1..28f44636 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -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"
diff --git a/spec/factories/api_tokens.rb b/spec/factories/api_tokens.rb
new file mode 100644
index 00000000..e2d64201
--- /dev/null
+++ b/spec/factories/api_tokens.rb
@@ -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
diff --git a/spec/integration/api/models/project.rb b/spec/integration/api/models/project.rb
new file mode 100644
index 00000000..aed1e6dd
--- /dev/null
+++ b/spec/integration/api/models/project.rb
@@ -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
diff --git a/spec/integration/api/models/projects.rb b/spec/integration/api/models/projects.rb
new file mode 100644
index 00000000..692b041a
--- /dev/null
+++ b/spec/integration/api/models/projects.rb
@@ -0,0 +1,6 @@
+SwaggerSchemas::PROJECTS = {
+ type: :array,
+ items: {
+ '$ref' => '#/components/schemas/project'
+ }
+}.freeze
diff --git a/spec/integration/api/swagger_schemas.rb b/spec/integration/api/swagger_schemas.rb
new file mode 100644
index 00000000..cea36a82
--- /dev/null
+++ b/spec/integration/api/swagger_schemas.rb
@@ -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
diff --git a/spec/integration/api/swagger_schemas_spec.rb b/spec/integration/api/swagger_schemas_spec.rb
new file mode 100644
index 00000000..205796e7
--- /dev/null
+++ b/spec/integration/api/swagger_schemas_spec.rb
@@ -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
diff --git a/spec/integration/api/v1/deployments_spec.rb b/spec/integration/api/v1/deployments_spec.rb
new file mode 100644
index 00000000..9e58c3f6
--- /dev/null
+++ b/spec/integration/api/v1/deployments_spec.rb
@@ -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
diff --git a/spec/integration/api/v1/projects_spec.rb b/spec/integration/api/v1/projects_spec.rb
new file mode 100644
index 00000000..3c04da83
--- /dev/null
+++ b/spec/integration/api/v1/projects_spec.rb
@@ -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
diff --git a/spec/models/api_token_spec.rb b/spec/models/api_token_spec.rb
new file mode 100644
index 00000000..81c9cdd6
--- /dev/null
+++ b/spec/models/api_token_spec.rb
@@ -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
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
index c75a69b1..9a4832a3 100644
--- a/spec/rails_helper.rb
+++ b/spec/rails_helper.rb
@@ -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
diff --git a/spec/swagger_helper.rb b/spec/swagger_helper.rb
new file mode 100644
index 00000000..c3ac6676
--- /dev/null
+++ b/spec/swagger_helper.rb
@@ -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
diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml
new file mode 100644
index 00000000..f7f41af3
--- /dev/null
+++ b/swagger/v1/swagger.yaml
@@ -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