diff --git a/Gemfile b/Gemfile index bc4a7f0..3750ca2 100644 --- a/Gemfile +++ b/Gemfile @@ -12,5 +12,9 @@ gem 'sprockets-rails' gem 'rack-cors' +gem "bcrypt", "~> 3.1.7" + +gem 'jwt' + # Start debugger with binding.b [https://github.com/ruby/debug] # gem "debug", ">= 1.0.0" diff --git a/Gemfile.lock b/Gemfile.lock index dfcca8f..057f064 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -85,6 +85,7 @@ GEM mutex_m tzinfo (~> 2.0) base64 (0.2.0) + bcrypt (3.1.20) bigdecimal (3.1.8) builder (3.3.0) concurrent-ruby (1.3.3) @@ -103,6 +104,8 @@ GEM irb (1.14.0) rdoc (>= 4.0.0) reline (>= 0.4.2) + jwt (2.8.2) + base64 loofah (2.22.0) crass (~> 1.0.2) nokogiri (>= 1.12.0) @@ -201,6 +204,8 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) + jwt oas_rails! puma rack-cors diff --git a/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb b/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb index ff34541..6fec8d2 100644 --- a/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb +++ b/lib/generators/oas_rails/config/templates/oas_rails_initializer.rb @@ -1,5 +1,6 @@ # config/initializers/oas_rails.rb OasRails.configure do |config| + # Basic Information about the API config.info.title = 'OasRails' config.info.summary = 'OasRails: Automatic Interactive API Documentation for Rails' config.info.description = <<~HEREDOC @@ -34,17 +35,55 @@ Explore your API documentation and enjoy the power of OasRails! For more information and advanced usage, visit the [OasRails GitHub repository](https://github.com/a-chacon/oas_rails). - - HEREDOC config.info.contact.name = 'a-chacon' config.info.contact.email = 'andres.ch@proton.me' config.info.contact.url = 'https://a-chacon.com' + + # Servers Information. For more details follow: https://spec.openapis.org/oas/latest.html#server-object config.servers = [{ url: 'http://localhost:3000', description: 'Local' }] + + # Tag Information. For more details follow: https://spec.openapis.org/oas/latest.html#tag-object config.tags = [{ name: "Users", description: "Manage the `amazing` Users table." }] - # config.default_tags_from = :namespace # Could be: :namespace or :controller - # config.autodiscover_request_body = true # Try to get request body for create and update methods based on the controller name. - # config.autodiscover_responses = true # Looks for renders in your source code and try to generate the responses. - # config.api_path = "/" # set this config if your api is in a different namespace other than / + # Optional Settings (Uncomment to use) + + # Extract default tags of operations from namespace or controller. Can be set to :namespace or :controller + # config.default_tags_from = :namespace + + # Automatically detect request bodies for create/update methods + # Default: true + # config.autodiscover_request_body = false + + # Automatically detect responses from controller renders + # Default: true + # config.autodiscover_responses = false + + # API path configuration if your API is under a different namespace + # config.api_path = "/" + + # ####################### + # Authentication Settings + # ####################### + + # Whether to authenticate all routes by default + # Default is true; set to false if you don't want all routes to include secutrity schemas by default + # config.authenticate_all_routes_by_default = true + + # Default security schema used for authentication + # Choose a predefined security schema + # [:api_key_cookie, :api_key_header, :api_key_query, :basic, :bearer, :bearer_jwt, :mutual_tls] + # config.security_schema = :bearer + + # Custom security schemas + # You can uncomment and modify to use custom security schemas + # Please follow the documentation: https://spec.openapis.org/oas/latest.html#security-scheme-object + # + # config.security_schemas = { + # bearer:{ + # "type": "apiKey", + # "name": "api_key", + # "in": "header" + # } + # } end diff --git a/lib/oas_rails.rb b/lib/oas_rails.rb index 186b21a..e8da353 100644 --- a/lib/oas_rails.rb +++ b/lib/oas_rails.rb @@ -2,30 +2,33 @@ require "method_source" require "esquema" -require_relative 'oas_rails/version' -require_relative 'oas_rails/engine' -require_relative 'oas_rails/oas_base' -require_relative 'oas_rails/configuration' -require_relative 'oas_rails/specification' -require_relative 'oas_rails/route_extractor' -require_relative 'oas_rails/oas_route' -require_relative 'oas_rails/operation' +module OasRails + require "oas_rails/version" + require "oas_rails/engine" -require_relative 'oas_rails/info' -require_relative 'oas_rails/contact' -require_relative 'oas_rails/paths' -require_relative 'oas_rails/path_item' -require_relative 'oas_rails/parameter' -require_relative 'oas_rails/tag' -require_relative 'oas_rails/license' -require_relative 'oas_rails/server' -require_relative "oas_rails/request_body" -require_relative "oas_rails/media_type" -require_relative 'oas_rails/yard/oas_yard_factory' -require_relative "oas_rails/response" -require_relative "oas_rails/responses" + autoload :OasBase, "oas_rails/oas_base" + autoload :Configuration, "oas_rails/configuration" + autoload :Specification, "oas_rails/specification" + autoload :RouteExtractor, "oas_rails/route_extractor" + autoload :OasRoute, "oas_rails/oas_route" + autoload :Operation, "oas_rails/operation" + autoload :Info, "oas_rails/info" + autoload :Contact, "oas_rails/contact" + autoload :Paths, "oas_rails/paths" + autoload :PathItem, "oas_rails/path_item" + autoload :Parameter, "oas_rails/parameter" + autoload :Tag, "oas_rails/tag" + autoload :License, "oas_rails/license" + autoload :Server, "oas_rails/server" + autoload :RequestBody, "oas_rails/request_body" + autoload :MediaType, "oas_rails/media_type" + autoload :Response, "oas_rails/response" + autoload :Responses, "oas_rails/responses" + + module Yard + autoload :OasYardFactory, 'oas_rails/yard/oas_yard_factory' + end -module OasRails class << self def configure yield config @@ -43,7 +46,9 @@ def configure_yard! 'Parameter' => [:parameter, :with_parameter], 'Response' => [:response, :with_response], 'Endpoint Tags' => [:tags], - 'Summary' => [:summary] + 'Summary' => [:summary], + 'No Auth' => [:no_auth], + 'Auth methods' => [:auth, :with_types] } yard_tags.each do |tag_name, (method_name, handler)| ::YARD::Tags::Library.define_tag(tag_name, method_name, handler) diff --git a/lib/oas_rails/configuration.rb b/lib/oas_rails/configuration.rb index 4a736a5..4929ebb 100644 --- a/lib/oas_rails/configuration.rb +++ b/lib/oas_rails/configuration.rb @@ -1,17 +1,26 @@ module OasRails class Configuration - attr_accessor :info, :default_tags_from, :autodiscover_request_body, :autodiscover_responses, :api_path - attr_reader :servers, :tags + attr_accessor :info, :default_tags_from, :autodiscover_request_body, :autodiscover_responses, :api_path, :security_schemas, :authenticate_all_routes_by_default + attr_reader :servers, :tags, :security_schema - def initialize(**kwargs) + def initialize @info = Info.new - @servers = kwargs[:servers] || default_servers + @servers = default_servers @tags = [] @swagger_version = '3.1.0' @default_tags_from = "namespace" @autodiscover_request_body = true @autodiscover_responses = true @api_path = "/" + @authenticate_all_routes_by_default = true + @security_schema = nil + @security_schemas = {} + end + + def security_schema=(value) + return unless (security_schema = DEFAULT_SECURITY_SCHEMES[value]) + + @security_schemas = { value => security_schema } end def default_servers @@ -26,4 +35,45 @@ def tags=(value) @tags = value.map { |t| Tag.new(name: t[:name], description: t[:description]) } end end + + DEFAULT_SECURITY_SCHEMES = { + api_key_cookie: { + type: "apiKey", + in: "cookie", + name: "api_key", + description: "An API key that will be supplied in a named cookie." + }, + api_key_header: { + type: "apiKey", + in: "header", + name: "X-API-Key", + description: "An API key that will be supplied in a named header." + }, + api_key_query: { + type: "apiKey", + in: "query", + name: "apiKey", + description: "An API key that will be supplied in a named query parameter." + }, + basic: { + type: "http", + scheme: "basic", + description: "Basic auth that takes a base64'd combination of `user:password`." + }, + bearer: { + type: "http", + scheme: "bearer", + description: "A bearer token that will be supplied within an `Authorization` header as `bearer `." + }, + bearer_jwt: { + type: "http", + scheme: "bearer", + bearerFormat: "JWT", + description: "A bearer token that will be supplied within an `Authorization` header as `bearer `. In this case, the format of the token is specified as JWT." + }, + mutual_tls: { + type: "mutualTLS", + description: "Requires a specific mutual TLS certificate to use when making an HTTP request." + } + }.freeze end diff --git a/lib/oas_rails/oas_base.rb b/lib/oas_rails/oas_base.rb index c512056..7505447 100644 --- a/lib/oas_rails/oas_base.rb +++ b/lib/oas_rails/oas_base.rb @@ -13,7 +13,8 @@ def to_spec value end - hash[camel_case_key] = processed_value unless (processed_value.is_a?(Hash) || processed_value.is_a?(Array)) && processed_value.empty? + # hash[camel_case_key] = processed_value unless (processed_value.is_a?(Hash) || processed_value.is_a?(Array)) && processed_value.empty? + hash[camel_case_key] = processed_value end hash end diff --git a/lib/oas_rails/operation.rb b/lib/oas_rails/operation.rb index 21a328a..e599c36 100644 --- a/lib/oas_rails/operation.rb +++ b/lib/oas_rails/operation.rb @@ -1,6 +1,6 @@ module OasRails class Operation < OasBase - attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses + attr_accessor :tags, :summary, :description, :operation_id, :parameters, :method, :docstring, :request_body, :responses, :security def initialize(method:, summary:, operation_id:, **kwargs) super() @@ -12,6 +12,7 @@ def initialize(method:, summary:, operation_id:, **kwargs) @parameters = kwargs[:parameters] || [] @request_body = kwargs[:request_body] || {} @responses = kwargs[:responses] || {} + @security = kwargs[:security] || [] end class << self @@ -23,7 +24,8 @@ def from_oas_route(oas_route:) parameters = extract_parameters(oas_route:) request_body = extract_request_body(oas_route:) responses = extract_responses(oas_route:) - new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:) + security = extract_security(oas_route:) + new(method: oas_route.verb.downcase, summary:, operation_id:, tags:, description:, parameters:, request_body:, responses:, security:) end def extract_summary(oas_route:) @@ -112,6 +114,20 @@ def extract_responses(oas_route:) responses end + def extract_security(oas_route:) + return [] if oas_route.docstring.tags(:no_auth).any? + + if (methods = oas_route.docstring.tags(:auth).first) + OasRails.config.security_schemas.keys.map { |key| { key => [] } }.select do |schema| + methods.types.include?(schema.keys.first.to_s) + end + elsif OasRails.config.authenticate_all_routes_by_default + OasRails.config.security_schemas.keys.map { |key| { key => [] } } + else + [] + end + end + def external_docs; end end end diff --git a/lib/oas_rails/specification.rb b/lib/oas_rails/specification.rb index 4475ab6..f99dfe0 100644 --- a/lib/oas_rails/specification.rb +++ b/lib/oas_rails/specification.rb @@ -15,28 +15,48 @@ def to_json(*_args) {} end + # Create the Base of the OAS hash. + # @see https://spec.openapis.org/oas/latest.html#schema def base_spec { openapi: '3.1.0', info: OasRails.config.info.to_spec, servers: OasRails.config.servers.map(&:to_spec), - paths: paths_spec, - components: components_spec, - security: [], + paths:, + components:, + security:, tags: OasRails.config.tags.map(&:to_spec), externalDocs: {} } end - def paths_spec + # Create the Security Requirement Object. + # @see https://spec.openapis.org/oas/latest.html#security-requirement-object + def security + return [] unless OasRails.config.authenticate_all_routes_by_default + + OasRails.config.security_schemas.map { |key, _| { key => [] } } + end + + # Create the Paths Object For the Root of the OAS. + # @see https://spec.openapis.org/oas/latest.html#paths-object + def paths Paths.from_string_paths(string_paths: RouteExtractor.host_paths).to_spec end - def components_spec + # Created the Components Object For the Root of the OAS. + # @see https://spec.openapis.org/oas/latest.html#components-object + def components { - schemas: {}, parameters: {}, securitySchemas: {}, requestBodies: {}, responses: {}, + schemas: {}, parameters: {}, securitySchemes: security_schemas, requestBodies: {}, responses: {}, headers: {}, examples: {}, links: {}, callbacks: {} } end + + # Create the Security Schemas Array inside components field of the OAS. + # @see https://spec.openapis.org/oas/latest.html#security-scheme-object + def security_schemas + OasRails.config.security_schemas + end end end diff --git a/test/controllers/oas_rails/oas_rails_controller_test.rb b/test/controllers/oas_rails/oas_rails_controller_test.rb index 5816f93..b3ce98c 100644 --- a/test/controllers/oas_rails/oas_rails_controller_test.rb +++ b/test/controllers/oas_rails/oas_rails_controller_test.rb @@ -4,8 +4,13 @@ module OasRails class OasRailsControllerTest < ActionDispatch::IntegrationTest include Engine.routes.url_helpers + test 'should return the front' do + get '/docs' + assert_response :ok + end + test 'should return the oas' do - get '/docs/oas' + get '/docs.json' assert_response :ok end end diff --git a/test/dummy/Gemfile b/test/dummy/Gemfile index cf797cb..8da5054 100644 --- a/test/dummy/Gemfile +++ b/test/dummy/Gemfile @@ -24,7 +24,7 @@ gem 'jbuilder' # gem "kredis" # Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword] -# gem "bcrypt", "~> 3.1.7" +gem "bcrypt", "~> 3.1.7" # Windows does not include zoneinfo files, so bundle the tzinfo-data gem gem 'tzinfo-data', platforms: %i[windows jruby] @@ -56,3 +56,5 @@ group :test do gem 'capybara' gem 'selenium-webdriver' end + +gem 'jwt' diff --git a/test/dummy/Gemfile.lock b/test/dummy/Gemfile.lock index 9dac769..e14ab1e 100644 --- a/test/dummy/Gemfile.lock +++ b/test/dummy/Gemfile.lock @@ -78,6 +78,7 @@ GEM addressable (2.8.7) public_suffix (>= 2.0.2, < 7.0) base64 (0.2.0) + bcrypt (3.1.20) bigdecimal (3.1.8) bindex (0.8.1) bootsnap (1.18.3) @@ -112,6 +113,8 @@ GEM jbuilder (2.12.0) actionview (>= 5.0.0) activesupport (>= 5.0.0) + jwt (2.8.2) + base64 logger (1.6.0) loofah (2.22.0) crass (~> 1.0.2) @@ -249,10 +252,12 @@ PLATFORMS x86_64-linux DEPENDENCIES + bcrypt (~> 3.1.7) bootsnap capybara debug jbuilder + jwt puma (>= 5.0) rails (~> 7.1.3, >= 7.1.3.4) selenium-webdriver diff --git a/test/dummy/app/controllers/application_controller.rb b/test/dummy/app/controllers/application_controller.rb index 4ac8823..ad5ed97 100644 --- a/test/dummy/app/controllers/application_controller.rb +++ b/test/dummy/app/controllers/application_controller.rb @@ -1,2 +1,12 @@ class ApplicationController < ActionController::API + def authorize! + header = request.headers['Authorization'] + header = header.split.last if header + begin + @decoded = JsonWebToken.decode(header) + @current_user = User.find(@decoded[:user_id]) + rescue ActiveRecord::RecordNotFound, JWT::DecodeError => e + render json: { errors: e.message }, status: :unauthorized + end + end end diff --git a/test/dummy/app/controllers/projects_controller.rb b/test/dummy/app/controllers/projects_controller.rb index c9dbcda..c2f5768 100644 --- a/test/dummy/app/controllers/projects_controller.rb +++ b/test/dummy/app/controllers/projects_controller.rb @@ -2,6 +2,7 @@ class ProjectsController < ApplicationController before_action :set_project, only: %i[show update destroy] + # @tags projects def index @projects = Project.all @@ -12,6 +13,7 @@ def show render json: @project end + # @tags projects def create @project = Project.new(project_params) diff --git a/test/dummy/app/controllers/users_controller.rb b/test/dummy/app/controllers/users_controller.rb index f2db19d..6220b61 100644 --- a/test/dummy/app/controllers/users_controller.rb +++ b/test/dummy/app/controllers/users_controller.rb @@ -2,8 +2,24 @@ # Manage Users Here class UsersController < ApplicationController + before_action :authorize!, except: [:create, :login] before_action :set_user, only: %i[show update destroy] + # @summary Login + # @request_body Valid Login Params [Hash!] { email: String, password: String} + # @no_auth + def login + @user = User.find_by_email(params[:email]) + if @user&.authenticate(params[:password]) + token = JsonWebToken.encode(user_id: @user.id) + time = Time.now + 24.hours.to_i + render json: { token:, exp: time.strftime("%m-%d-%Y %H:%M"), + username: @user.name }, status: :ok + else + render json: { error: 'unauthorized' }, status: :unauthorized + end + end + # Returns a list of Users. # # Status can be -1(Deleted), 0(Inactive), 1(Active), 2(Expired) and 3(Cancelled). @@ -17,7 +33,7 @@ def index @users = User.all end - # Get a user by id. + # @summary Get a user by id. # # This method show a User by ID. The id must exist of other way it will be returning a 404. # @parameter id(path) [Integer] Used for identify the user. @@ -29,6 +45,7 @@ def show end # @summary Create a User Newwww + # @no_auth # # To act as connected accounts, clients can issue requests using the Stripe-Account special header. Make sure that this header contains a Stripe account ID, which usually starts with the acct_ prefix. # The value is set per-request as shown in the adjacent code sample. Methods on the returned object reuse the same account ID.ased on the strings @@ -49,7 +66,6 @@ def create # A `user` can be updated with this method # - There is no option # - It must work - # @tags users, update # @request_body User to be created [Hash] {user: { name: String, email: String, age: Integer}} # @request_body_example Update user [Hash] {user: {name: "Luis", email: "luis@gmail.com"}} # @request_body_example Complete User [Hash] {user: {name: "Luis", email: "luis@gmail.com", age: 21}} @@ -76,6 +92,10 @@ def set_user # Only allow a list of trusted parameters through. def user_params - params.require(:user).permit(:name, :email) + params.require(:user).permit(:name, :email, :password) + end + + def login_params + params.permit(:email, :password) end end diff --git a/test/dummy/app/models/user.rb b/test/dummy/app/models/user.rb index a3f90a4..2ae5f05 100644 --- a/test/dummy/app/models/user.rb +++ b/test/dummy/app/models/user.rb @@ -1,3 +1,5 @@ class User < ApplicationRecord + has_secure_password validates :name, presence: true + validates :email, presence: true, uniqueness: true end diff --git a/test/dummy/config/initializers/oas_rails.rb b/test/dummy/config/initializers/oas_rails.rb index 3956fc8..1c6def2 100644 --- a/test/dummy/config/initializers/oas_rails.rb +++ b/test/dummy/config/initializers/oas_rails.rb @@ -27,4 +27,8 @@ config.servers = [{ url: 'http://localhost:3000', description: 'Local' }, { url: 'https://example.rb', description: 'Dev' }] config.tags = [{ name: "Users", description: "Manage the `amazing` Users table." }] + + config.security_schema = :bearer + # config.security_schemas = { + # } end diff --git a/test/dummy/config/routes.rb b/test/dummy/config/routes.rb index 4fed3bc..4cb0502 100644 --- a/test/dummy/config/routes.rb +++ b/test/dummy/config/routes.rb @@ -1,4 +1,5 @@ Rails.application.routes.draw do + post '/users/login', to: 'users#login' resources :users, shallow: true do resources :projects end diff --git a/test/dummy/db/migrate/20240712004600_create_users.rb b/test/dummy/db/migrate/20240712004600_create_users.rb index 5988745..0903bae 100644 --- a/test/dummy/db/migrate/20240712004600_create_users.rb +++ b/test/dummy/db/migrate/20240712004600_create_users.rb @@ -3,6 +3,7 @@ def change create_table :users do |t| t.string :name t.string :email + t.string :password_digest t.timestamps end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index b7234e8..fe4db77 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -23,6 +23,7 @@ create_table "users", force: :cascade do |t| t.string "name" t.string "email" + t.string "password_digest" t.datetime "created_at", null: false t.datetime "updated_at", null: false end diff --git a/test/dummy/lib/json_web_token.rb b/test/dummy/lib/json_web_token.rb new file mode 100644 index 0000000..63af607 --- /dev/null +++ b/test/dummy/lib/json_web_token.rb @@ -0,0 +1,13 @@ +class JsonWebToken + SECRET_KEY = Rails.application.credentials.secret_key_base.to_s + + def self.encode(payload, exp = 24.hours.from_now) + payload[:exp] = exp.to_i + JWT.encode(payload, SECRET_KEY) + end + + def self.decode(token) + decoded = JWT.decode(token, SECRET_KEY)[0] + HashWithIndifferentAccess.new decoded + end +end