docit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ class UiController < ActionController::Base
5
+ def index
6
+ render html: swagger_ui_html.html_safe, layout: false
7
+ end
8
+
9
+ def spec
10
+ RouteInspector.eager_load_controllers!
11
+ render json: SchemaGenerator.generate
12
+ end
13
+
14
+ private
15
+
16
+ def swagger_ui_html
17
+ spec_url = "#{request.base_url}#{Docit::Engine.routes.url_helpers.spec_path}"
18
+ escaped_title = ERB::Util.html_escape(Docit.configuration.title)
19
+
20
+ <<~HTML
21
+ <!DOCTYPE html>
22
+ <html lang="en">
23
+ <head>
24
+ <meta charset="UTF-8">
25
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
26
+ <title>#{escaped_title}</title>
27
+ <link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@5/swagger-ui.css" />
28
+ <style>
29
+ html { box-sizing: border-box; overflow-y: scroll; }
30
+ *, *:before, *:after { box-sizing: inherit; }
31
+ body { margin: 0; background: #fafafa; }
32
+ </style>
33
+ </head>
34
+ <body>
35
+ <div id="swagger-ui"></div>
36
+ <script src="https://unpkg.com/swagger-ui-dist@5/swagger-ui-bundle.js"></script>
37
+ <script>
38
+ SwaggerUIBundle({
39
+ url: "#{spec_url}",
40
+ dom_id: '#swagger-ui',
41
+ presets: [
42
+ SwaggerUIBundle.presets.apis,
43
+ SwaggerUIBundle.SwaggerUIStandalonePreset
44
+ ],
45
+ layout: "BaseLayout",
46
+ deepLinking: true,
47
+ showExtensions: true,
48
+ showCommonExtensions: true
49
+ })
50
+ </script>
51
+ </body>
52
+ </html>
53
+ HTML
54
+ end
55
+ end
56
+ end
data/config/routes.rb ADDED
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ Docit::Engine.routes.draw do
4
+ root to: "ui#index"
5
+ get "spec", to: "ui#spec", defaults: { format: :json }
6
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Builders
5
+ # Collects query, path, and header parameters for an operation.
6
+ class ParameterBuilder
7
+ attr_reader :params
8
+
9
+ def initialize
10
+ @params = []
11
+ end
12
+
13
+ def add(name, location:, type: :string, required: false, description: nil, example: nil, enum: nil, **opts)
14
+ param = {
15
+ name: name.to_s,
16
+ in: location.to_s,
17
+ required: required,
18
+ schema: { type: type.to_s }
19
+ }
20
+ param[:description] = description if description
21
+ param[:schema][:enum] = enum if enum
22
+ param[:schema][:example] = example if example
23
+ param[:schema].merge!(opts)
24
+ @params << param
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Builders
5
+ # Builds the schema for a request body, including properties,
6
+ # required fields, and schema references.
7
+ class RequestBodyBuilder
8
+ attr_reader :properties, :required, :content_type, :schema_ref
9
+
10
+ def initialize(required: false, content_type: "application/json")
11
+ @required = required
12
+ @content_type = content_type
13
+ @properties = []
14
+ @schema_ref = nil
15
+ end
16
+
17
+ def schema(ref:)
18
+ @schema_ref = ref.to_sym
19
+ end
20
+
21
+ def property(name, type:, required: false, format: nil, example: nil, enum: nil, description: nil, items: nil,
22
+ **opts, &block)
23
+ prop = { name: name, type: type, required: required }
24
+ prop[:format] = format if format
25
+ prop[:example] = example if example
26
+ prop[:enum] = enum if enum
27
+ prop[:description] = description if description
28
+ prop[:items] = items if items
29
+ prop.merge!(opts)
30
+
31
+ if block_given?
32
+ nested = self.class.new(required: @required, content_type: @content_type)
33
+ nested.instance_eval(&block)
34
+ prop[:children] = nested.properties
35
+ end
36
+
37
+ @properties << prop
38
+ end
39
+
40
+ def required_properties
41
+ @properties.select { |prop| prop[:required] }.map { |prop| prop[:name].to_s }
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module Builders
5
+ # Builds the schema for a single HTTP response, including properties,
6
+ # examples, and schema references.
7
+ class ResponseBuilder
8
+ attr_reader :status, :description, :properties, :examples, :schema_ref
9
+
10
+ def initialize(status:, description:)
11
+ @status = status
12
+ @description = description
13
+ @properties = []
14
+ @examples = []
15
+ @schema_ref = nil
16
+ end
17
+
18
+ def schema(ref:)
19
+ @schema_ref = ref.to_sym
20
+ end
21
+
22
+ def property(name, type:, format: nil, example: nil, enum: nil, description: nil, items: nil, **opts, &block)
23
+ prop = { name: name, type: type }
24
+ prop[:format] = format if format
25
+ prop[:example] = example if example
26
+ prop[:enum] = enum if enum
27
+ prop[:description] = description if description
28
+ prop[:items] = items if items
29
+ prop.merge!(opts)
30
+
31
+ if block_given?
32
+ nested = self.class.new(status: @status, description: @description)
33
+ nested.instance_eval(&block)
34
+ prop[:children] = nested.properties
35
+ end
36
+
37
+ @properties << prop
38
+ end
39
+
40
+ def example(name, value, description: nil)
41
+ ex = { name: name, value: value }
42
+ ex[:description] = description if description
43
+ @examples << ex
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # Holds global API documentation settings: metadata, authentication, tags, and servers.
5
+ class Configuration
6
+ attr_accessor :title, :version, :description, :base_url
7
+
8
+ def initialize
9
+ @title = "API Documentation"
10
+ @version = "1.0.0"
11
+ @description = ""
12
+ @base_url = "/"
13
+ @security_schemes = {}
14
+ @tags = []
15
+ @servers = []
16
+ end
17
+
18
+ def auth(type, **options)
19
+ case type.to_s.downcase
20
+ when "basic"
21
+ @security_schemes[:basic_auth] = {
22
+ type: "http",
23
+ scheme: "basic"
24
+ }
25
+ when "bearer"
26
+ @security_schemes[:bearer_auth] = {
27
+ type: "http",
28
+ scheme: "bearer",
29
+ bearerFormat: options[:bearer_format] || "JWT"
30
+ }
31
+ when "api_key"
32
+ @security_schemes[:api_key] = {
33
+ type: "apiKey",
34
+ name: options[:name] || "X-API-Key",
35
+ in: options[:location] || "header"
36
+ }
37
+ else
38
+ raise ArgumentError, "Unsupported auth type: #{type}"
39
+ end
40
+ end
41
+
42
+ def security_schemes
43
+ @security_schemes.dup
44
+ end
45
+
46
+ def tag(name, description: nil)
47
+ entry = { name: name.to_s }
48
+ entry[:description] = description if description
49
+ @tags << entry
50
+ end
51
+
52
+ def tags
53
+ @tags.dup
54
+ end
55
+
56
+ def server(url, description: nil)
57
+ entry = { url: url.to_s }
58
+ entry[:description] = description if description
59
+ @servers << entry
60
+ end
61
+
62
+ def servers
63
+ @servers.dup
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # DocFile lets you define API documentation in separate files,
5
+ # keeping your controllers clean. Extend any module with DocFile,
6
+ # then use `doc :action_name do ... end` to define docs.
7
+ #
8
+ # Usage:
9
+ #
10
+ # # app/docs/users_docs.rb
11
+ # module UsersDocs
12
+ # extend Docit::DocFile
13
+ #
14
+ # doc :index do
15
+ # summary "List users"
16
+ # tags "Users"
17
+ # response 200, "Users retrieved"
18
+ # end
19
+ #
20
+ # doc :create do
21
+ # summary "Create a user"
22
+ # tags "Users"
23
+ # request_body required: true do
24
+ # property :email, type: :string, required: true
25
+ # end
26
+ # response 201, "User created"
27
+ # end
28
+ # end
29
+ #
30
+ # # app/controllers/users_controller.rb
31
+ # class UsersController < ApplicationController
32
+ # use_docs UsersDocs
33
+ #
34
+ # def index; end
35
+ # def create; end
36
+ # end
37
+ #
38
+ module DocFile
39
+ def self.extended(base)
40
+ base.instance_variable_set(:@_docs, {})
41
+ end
42
+
43
+ # The block receives the same DSL as swagger_doc.
44
+ def doc(action, &block)
45
+ @_docs[action.to_sym] = block
46
+ end
47
+
48
+ def [](action)
49
+ @_docs[action.to_sym]
50
+ end
51
+
52
+ def actions
53
+ @_docs.keys
54
+ end
55
+ end
56
+ end
data/lib/docit/dsl.rb ADDED
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # Included in all Rails controllers via the Engine.
5
+ # Provides +swagger_doc+ and +use_docs+ class methods.
6
+ module DSL
7
+ def self.included(base)
8
+ base.extend(ClassMethods)
9
+ end
10
+
11
+ module ClassMethods
12
+ def swagger_doc(action, &block)
13
+ operation = Operation.new(
14
+ controller: name,
15
+ action: action
16
+ )
17
+ operation.instance_eval(&block) if block_given?
18
+ Registry.register(operation)
19
+ end
20
+
21
+ def use_docs(doc_module)
22
+ doc_module.actions.each do |action|
23
+ swagger_doc(action, &doc_module[action])
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "docit"
4
+
5
+ module Docit
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace Docit
8
+
9
+ initializer "docit.include_dsl" do
10
+ ActiveSupport.on_load(:action_controller_api) do
11
+ include Docit::DSL
12
+ end
13
+
14
+ ActiveSupport.on_load(:action_controller_base) do
15
+ include Docit::DSL
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # Represents the documentation for a single controller action.
5
+ # Created by +swagger_doc+ and stored in the {Registry}.
6
+ class Operation
7
+ attr_reader :controller, :action, :_summary, :_description,
8
+ :_tags, :_responses, :_request_body, :_parameters,
9
+ :_security, :_deprecated
10
+
11
+ def initialize(controller:, action:)
12
+ @controller = controller
13
+ @action = action.to_s
14
+ @_tags = []
15
+ @_responses = []
16
+ @_parameters = Builders::ParameterBuilder.new
17
+ @_request_body = nil
18
+ @_security = []
19
+ @_deprecated = false
20
+ end
21
+
22
+ def summary(text)
23
+ @_summary = text
24
+ end
25
+
26
+ def description(text)
27
+ @_description = text
28
+ end
29
+
30
+ def tags(*tags_list)
31
+ @_tags = tags_list.flatten
32
+ end
33
+
34
+ def deprecated(value: true)
35
+ @_deprecated = value
36
+ end
37
+
38
+ def security(scheme)
39
+ @_security << scheme
40
+ end
41
+
42
+ def parameter(name, location:, type: :string, required: false, description: nil, **opts)
43
+ @_parameters.add(name, location: location, type: type, required: required, description: description, **opts)
44
+ end
45
+
46
+ def request_body(required: false, content_type: "application/json", &block)
47
+ builder = Builders::RequestBodyBuilder.new(required: required, content_type: content_type)
48
+ builder.instance_eval(&block) if block_given?
49
+ @_request_body = builder
50
+ end
51
+
52
+ def response(status, description = "", &block)
53
+ builder = Builders::ResponseBuilder.new(status: status, description: description)
54
+ builder.instance_eval(&block) if block_given?
55
+ @_responses << builder
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # Central store for all documented operations.
5
+ class Registry
6
+ class << self
7
+ def operations
8
+ @operations ||= []
9
+ end
10
+
11
+ def register(operation)
12
+ operations << operation
13
+ end
14
+
15
+ def find(controller:, action:)
16
+ operations.find do |op|
17
+ op.controller == controller && op.action == action
18
+ end
19
+ end
20
+
21
+ def clear!
22
+ @operations = []
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # Introspects Rails routes to map controller actions to HTTP paths and methods.
5
+ class RouteInspector
6
+ VALID_METHODS = %w[get post put patch delete].freeze
7
+
8
+ # Eagerly loads controller classes so swagger_doc/use_docs macros run before spec generation.
9
+ def self.eager_load_controllers!
10
+ return if defined?(Rails).nil? || Rails.application.routes.nil?
11
+
12
+ controller_paths = Rails.application.routes.routes.filter_map do |route|
13
+ route.defaults[:controller]
14
+ end.uniq
15
+
16
+ controller_paths.each do |path|
17
+ class_name = "#{path}_controller".camelize
18
+ class_name.constantize
19
+ rescue NameError
20
+ # Skip controllers that can't be loaded (e.g., Rails internal routes)
21
+ end
22
+ end
23
+
24
+ def self.routes_for(controller_name, action_name)
25
+ return [] if defined?(Rails).nil? || Rails.application.routes.nil?
26
+
27
+ action = action_name.to_s
28
+
29
+ # Convert Api::V1::AuthController to api/v1/auth
30
+ controller_path = controller_name.underscore.delete_suffix("_controller").gsub("::", "/")
31
+
32
+ Rails.application.routes.routes.filter_map do |route|
33
+ next if route.defaults[:controller] != controller_path
34
+ next if route.defaults[:action] != action
35
+
36
+ verb = extract_verb(route)
37
+ next if VALID_METHODS.exclude?(verb)
38
+
39
+ { path: normalize_path(route.path.spec.to_s), method: verb }
40
+ end
41
+ end
42
+
43
+ def self.extract_verb(route)
44
+ verb = route.verb
45
+ verb = verb.source if verb.is_a?(Regexp)
46
+ verb.to_s.downcase.gsub(/[^a-z]/, "")
47
+ end
48
+
49
+ private_class_method :extract_verb
50
+
51
+ def self.normalize_path(path)
52
+ path
53
+ .gsub("(.:format)", "")
54
+ .gsub(/\(\.?:(\w+)\)/, '{\1}')
55
+ .gsub(/:(\w+)/, '{\1}')
56
+ end
57
+
58
+ private_class_method :normalize_path
59
+ end
60
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ # A reusable schema definition that can be referenced via +$ref+.
5
+ # Defined with {Docit.define_schema} and rendered under
6
+ # +components/schemas+ in the OpenAPI spec.
7
+ class SchemaDefinition
8
+ attr_reader :name, :properties
9
+
10
+ def initialize(name)
11
+ @name = name
12
+ @properties = []
13
+ end
14
+
15
+ def property(prop_name, type:, format: nil, example: nil, enum: nil, description: nil, items: nil, **opts, &block)
16
+ prop = { name: prop_name, type: type }
17
+ prop[:format] = format if format
18
+ prop[:example] = example if example
19
+ prop[:enum] = enum if enum
20
+ prop[:description] = description if description
21
+ prop[:items] = items if items
22
+ prop.merge!(opts)
23
+
24
+ if block_given?
25
+ nested = self.class.new(:"#{@name}_#{prop_name}")
26
+ nested.instance_eval(&block)
27
+ prop[:children] = nested.properties
28
+ end
29
+
30
+ @properties << prop
31
+ end
32
+ end
33
+ end