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 @@ +
+ + + + + + + + + + + + + <% current_user.api_tokens.order(created_at: :desc).each do |api_token| %> + + + + + + + + <% end %> + +
NameAPI TokenLast UsedExpires AtActions
<%= 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" %> +
+
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" %> - + <% 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