fdoc 0.2.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (36) hide show
  1. data/Gemfile +2 -0
  2. data/README.md +120 -0
  3. data/Rakefile +7 -0
  4. data/bin/fdoc_to_html +77 -0
  5. data/fdoc.gemspec +36 -0
  6. data/lib/endpoint-schema.yaml +30 -0
  7. data/lib/fdoc.rb +46 -0
  8. data/lib/fdoc/endpoint.rb +110 -0
  9. data/lib/fdoc/endpoint_scaffold.rb +132 -0
  10. data/lib/fdoc/meta_service.rb +46 -0
  11. data/lib/fdoc/presenters/endpoint_presenter.rb +152 -0
  12. data/lib/fdoc/presenters/html_presenter.rb +58 -0
  13. data/lib/fdoc/presenters/meta_service_presenter.rb +65 -0
  14. data/lib/fdoc/presenters/response_code_presenter.rb +32 -0
  15. data/lib/fdoc/presenters/schema_presenter.rb +138 -0
  16. data/lib/fdoc/presenters/service_presenter.rb +56 -0
  17. data/lib/fdoc/service.rb +88 -0
  18. data/lib/fdoc/spec_watcher.rb +48 -0
  19. data/lib/fdoc/templates/endpoint.html.erb +75 -0
  20. data/lib/fdoc/templates/meta_service.html.erb +60 -0
  21. data/lib/fdoc/templates/service.html.erb +54 -0
  22. data/lib/fdoc/templates/styles.css +63 -0
  23. data/spec/fdoc/endpoint_scaffold_spec.rb +242 -0
  24. data/spec/fdoc/endpoint_spec.rb +243 -0
  25. data/spec/fdoc/presenters/endpoint_presenter_spec.rb +93 -0
  26. data/spec/fdoc/presenters/service_presenter_spec.rb +18 -0
  27. data/spec/fdoc/service_spec.rb +63 -0
  28. data/spec/fixtures/members/add-PUT.fdoc +20 -0
  29. data/spec/fixtures/members/draft-POST.fdoc +5 -0
  30. data/spec/fixtures/members/list/GET.fdoc +50 -0
  31. data/spec/fixtures/members/list/complex-params-GET.fdoc +94 -0
  32. data/spec/fixtures/members/list/filter-GET.fdoc +60 -0
  33. data/spec/fixtures/members/members.fdoc.service +11 -0
  34. data/spec/fixtures/sample_group.fdoc.meta +9 -0
  35. data/spec/spec_helper.rb +2 -0
  36. metadata +174 -0
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "https://rubygems.org/"
2
+ gemspec
@@ -0,0 +1,120 @@
1
+ # fdoc: Documentation format and verification
2
+
3
+ High-quality documentation is extremely useful, but maintaining it is often a pain. We aim to create a tool to facilitate easy creation and maintenance of API documentation.
4
+
5
+ In a Rails app, fdoc can help document an API as well as verify that requests and responses adhere to their appropriate schemata.
6
+
7
+ Outside a Rails app, fdoc can provide a common format for API documentation, as well as the ability to generate basic HTML pages for humans to consume.
8
+
9
+ fdoc is short for Farnsdocs. They are named for everybody's favorite, good news-bearing, crotchety old man, Professor Farnsworth.
10
+
11
+ ![Professor Farnsworth][github_img]
12
+
13
+ ## Usage
14
+
15
+ ### In a Rails app
16
+
17
+ Add fdoc to your Gemfile.
18
+
19
+ gem 'fdoc'
20
+
21
+ Tell fdoc where to look for .fdoc files. By default, fdoc will look in `docs/fdoc`, but you can change this behavior to look anywhere. This fits best in something like a spec\_helper file.
22
+
23
+ require 'fdoc'
24
+
25
+ Fdoc.service_path = "path/to/your/fdocs"
26
+
27
+ fdoc is built to work around controller specs in rspec, and provides `Fdoc::SpecWatcher` as a mixin. Make sure to include it *inside* your top level describe.
28
+
29
+ require 'fdoc/spec_watcher'
30
+
31
+ describe MembersController do
32
+ include Fdoc::SpecWatcher
33
+ ...
34
+ end
35
+
36
+ To enable fdoc for an endpoint, add the `fdoc` option with the path to the endpoint. fdoc will intercept all calls to `get`, `post`, `put`, and `delete` and verify those parameters accordingly.
37
+
38
+ context "#show", :fdoc => 'members/list' do
39
+ ..
40
+ end
41
+
42
+ fdoc also has a scaffolding mode, where it attemps to infer the schema of a request based on sample responses. The interface is exactly the same as verifying, just set the environment variable `FDOC_SCAFFOLD=true`.
43
+
44
+ FDOC_SCAFFOLD=true bundle exec rspec spec/controllers
45
+
46
+ For more information on scaffolding, please see the more in-depth [fdoc scaffolding example][github_scaffold].
47
+
48
+ ### Outside a Rails App
49
+
50
+ fdoc provides the `fdoc_to_html` script to transform a directory of `.fdoc` files into more human-readable HTML.
51
+
52
+ In this repo, try running:
53
+
54
+ bin/fdoc_to_html spec/fixtures html
55
+
56
+ ## Example
57
+
58
+ `.fdoc` files are YAML files based on [JSON schema][json_schema] to describe API endpoints. They derive their endpoint path and verb from their filename.
59
+
60
+ - For more information on fdoc file naming conventions, please see the [fdoc file conventions guide][github_files].
61
+ - For more information on how fdoc uses JSON schema, please see the [json schema usage document][github_json].
62
+
63
+ Here is `members/list-POST.fdoc`:
64
+
65
+ description: The list of members.
66
+ requestParameters:
67
+ properties:
68
+ limit:
69
+ type: integer
70
+ required: no
71
+ default: 50
72
+ description: Limits the number of results returned, used for paging.
73
+ responseParameters:
74
+ properties:
75
+ members:
76
+ type: array
77
+ items:
78
+ title: member
79
+ description: Representation of a member
80
+ type: object
81
+ properties:
82
+ name:
83
+ description: Member's name
84
+ type: string
85
+ required: yes
86
+ example: Captain Smellypants
87
+ responseCodes:
88
+ - status: 200 OK
89
+ successful: yes
90
+ description: A list of current members
91
+ - status: 400 Bad Request
92
+ successful: no
93
+ description: Indicates malformed parameters
94
+
95
+
96
+ ## Goals
97
+
98
+ - Client engineers should be able to participate in documenting an API and
99
+ keeping it up to date.
100
+ - Server engineers should be able to test their implementations.
101
+ - The documentation should be as close to the code as possible.
102
+ - Branches, reviews, and merges are the appropriate way to update the docs.
103
+ - Experimental drafts should just live on branches and never get
104
+ merged into master.
105
+ - Specification alone is not enough, there needs to be room for discussion.
106
+
107
+ ## Contributing
108
+
109
+ Just fork and make a pull request! You will need to sign the [Individual Contributor License Agreement (CLA)][contrib_license] before we can merge your code.
110
+
111
+
112
+
113
+
114
+ [github_img]: https://github.com/square/fdoc/raw/master/docs/farnsworth.png
115
+ [github_scaffold]: https://github.com/square/fdoc/blob/master/docs/scaffold.md
116
+ [github_json]: https://github.com/square/fdoc/blob/master/docs/json_schema.md
117
+ [github_files]: https://github.com/square/fdoc/blob/master/docs/files.md
118
+
119
+ [json_schema]: http://json-schema.org/
120
+ [contrib_license]: https://spreadsheets.google.com/spreadsheet/viewform?formkey=dDViT2xzUHAwRkI3X3k5Z0lQM091OGc6MQ&ndplr=1
@@ -0,0 +1,7 @@
1
+ require "rake"
2
+ require "bundler"; Bundler.setup
3
+ require "rspec/core/rake_task"
4
+
5
+ RSpec::Core::RakeTask.new(:spec)
6
+
7
+ task :default => :spec
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require File.join(File.dirname(__FILE__), '../lib/fdoc')
4
+
5
+ if ARGV.length < 2
6
+ exec_name = File.basename($0)
7
+ abort "Usage: #{exec_name} [fdoc_directory] [html_directory] (url_base_path)"
8
+ end
9
+
10
+ fdoc_directory, html_directory, url_base_path = ARGV[0..2]
11
+ fdoc_directory = File.expand_path(fdoc_directory)
12
+ html_directory = File.expand_path(html_directory)
13
+
14
+ html_options = {
15
+ :html_directory => html_directory,
16
+ :static_html => true,
17
+ :url_base_path => url_base_path
18
+ }
19
+
20
+ def mkdir(dirname)
21
+ `mkdir -p #{dirname}` unless File.directory?(dirname)
22
+ end
23
+
24
+ mkdir(html_directory)
25
+
26
+ meta_service = Fdoc::MetaService.new(fdoc_directory)
27
+ services = if !meta_service.empty?
28
+ meta_service.services
29
+ else
30
+ Array(Fdoc::Service.new(fdoc_directory))
31
+ end
32
+
33
+ if !meta_service.empty?
34
+ meta_service_presenter= Fdoc::MetaServicePresenter.new(
35
+ meta_service, html_options
36
+ )
37
+
38
+ index_html_path = File.join(html_directory, "index.html")
39
+ File.open(index_html_path, "w") do |file|
40
+ file.write(meta_service_presenter.to_html)
41
+
42
+ puts "created #{index_html_path}"
43
+ end
44
+ end
45
+
46
+ css_path = File.join(File.dirname(__FILE__), '../lib/fdoc/templates/styles.css')
47
+ `cp "#{css_path}" "#{html_directory}"`
48
+ puts "created #{File.expand_path(css_path)}"
49
+
50
+ services.each do |service|
51
+ service_presenter = Fdoc::ServicePresenter.new(service, html_options)
52
+ html_sub_directory = if meta_service.empty?
53
+ html_directory
54
+ else
55
+ File.join(html_directory, service_presenter.slug_name)
56
+ end
57
+
58
+ mkdir(html_sub_directory)
59
+
60
+ index_html_path = File.join(html_sub_directory, "index.html")
61
+ File.open(index_html_path, "w") do |file|
62
+ file.write(service_presenter.to_html)
63
+
64
+ puts "created #{index_html_path}"
65
+ end
66
+
67
+ service_presenter.endpoints.flatten.each do |endpoint|
68
+ endpoint_html_path = File.join(html_sub_directory, endpoint.url)
69
+ mkdir(File.dirname(endpoint_html_path))
70
+
71
+ File.open(endpoint_html_path, "w") do |file|
72
+ file.write(endpoint.to_html)
73
+
74
+ puts "created #{endpoint_html_path}"
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,36 @@
1
+ # encoding: utf-8
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = "fdoc"
5
+
6
+ s.version = File.read("#{File.dirname(__FILE__)}/VERSION")
7
+ s.required_rubygems_version = Gem::Requirement.new(">= 0") if s.respond_to? :required_rubygems_version=
8
+ s.rubygems_version = "1.3.7"
9
+
10
+ s.authors = ["Matt Wilson", "Zach Margolis", "Sean Sorrell"]
11
+ s.email = "support@squareup.com"
12
+
13
+ s.date = "2011-11-07"
14
+ s.description = "A tool for documenting API endpoints."
15
+ s.summary = "A tool for documenting API endpoints."
16
+ s.homepage = "http://github.com/square/fdoc"
17
+
18
+ s.rdoc_options = ["--charset=UTF-8"]
19
+ s.extra_rdoc_files = [
20
+ "README.md"
21
+ ]
22
+ s.require_paths = ["lib"]
23
+ s.files = Dir['{lib,spec}/**/*'] + %w(fdoc.gemspec Rakefile README.md Gemfile)
24
+ s.test_files = Dir['spec/**/*']
25
+ s.bindir = "bin"
26
+ s.executables << "fdoc_to_html"
27
+
28
+ s.add_dependency("json")
29
+ s.add_dependency("json-schema", ">= 1.0.1")
30
+ s.add_dependency("kramdown")
31
+
32
+ s.add_development_dependency("rake")
33
+ s.add_development_dependency("rspec", "~> 2.5")
34
+ s.add_development_dependency("nokogiri")
35
+ s.add_development_dependency("cane")
36
+ end
@@ -0,0 +1,30 @@
1
+ type: object
2
+ title: endpoint
3
+ description: Describes an API endpoint
4
+ additionalProperties: no
5
+ properties:
6
+ description:
7
+ type: string
8
+ required: no
9
+ description: Free-form text describing the endpoint. Rendered with a Markdown parser.
10
+ requestParameters:
11
+ type: object
12
+ description: A schema for the request parameters
13
+ responseParameters:
14
+ type: object
15
+ description: A schema for the response parameters
16
+ responseCodes:
17
+ type: array
18
+ required: yes
19
+ items:
20
+ type: object
21
+ description: |
22
+ A status code is defined by its HTTP status code and whether or not it was successful
23
+ properties:
24
+ status:
25
+ type: string
26
+ description: An HTTP status code, such as 200 OK or 201 Created
27
+ successful:
28
+ type: boolean
29
+ description: |
30
+ Describes if the client can interpret this response as successful.
@@ -0,0 +1,46 @@
1
+ $:.unshift(File.dirname(__FILE__))
2
+
3
+ module Fdoc
4
+ DEFAULT_SERVICE_PATH = "docs/fdoc"
5
+
6
+ def self.scaffold_mode?
7
+ ENV['FDOC_SCAFFOLD']
8
+ end
9
+
10
+ def self.service_path=(service_path)
11
+ @service_path = service_path
12
+ end
13
+
14
+ def self.service_path
15
+ @service_path || DEFAULT_SERVICE_PATH
16
+ end
17
+
18
+ def self.decide_success_with(&block)
19
+ @success_block = block
20
+ end
21
+
22
+ def self.decide_success(*args)
23
+ if @success_block
24
+ @success_block.call(*args)
25
+ else
26
+ true
27
+ end
28
+ end
29
+
30
+ # Top-level fdoc validation error, abstract.
31
+ class ValidationError < StandardError; end
32
+
33
+ # Indicates an unknown response code.
34
+ class UndocumentedResponseCode < ValidationError; end
35
+ end
36
+
37
+ require 'fdoc/service'
38
+ require 'fdoc/meta_service'
39
+ require 'fdoc/endpoint'
40
+ require 'fdoc/endpoint_scaffold'
41
+ require 'fdoc/presenters/html_presenter'
42
+ require 'fdoc/presenters/service_presenter'
43
+ require 'fdoc/presenters/meta_service_presenter'
44
+ require 'fdoc/presenters/endpoint_presenter'
45
+ require 'fdoc/presenters/schema_presenter'
46
+ require 'fdoc/presenters/response_code_presenter'
@@ -0,0 +1,110 @@
1
+ require 'yaml'
2
+ require 'json-schema'
3
+
4
+ # Endpoints represent the schema for an API endpoint
5
+ # The #consume_* methods will raise exceptions if input differs from the schema
6
+ class Fdoc::Endpoint
7
+ attr_reader :service
8
+ attr_reader :endpoint_path
9
+
10
+ def initialize(endpoint_path, service=Fdoc::Service::DefaultService)
11
+ @endpoint_path = endpoint_path
12
+ @schema = YAML.load_file(@endpoint_path)
13
+ @service = service
14
+ end
15
+
16
+ def consume_request(params, successful=true)
17
+ if successful
18
+ schema = set_additional_properties_false_on(request_parameters.dup)
19
+ JSON::Validator.validate!(schema, stringify_keys(params))
20
+ end
21
+ end
22
+
23
+ def consume_response(params, status_code, successful=true)
24
+ response_code = response_codes.find do |rc|
25
+ rc["status"] == status_code && rc["successful"] == successful
26
+ end
27
+
28
+ if !response_code
29
+ raise Fdoc::UndocumentedResponseCode,
30
+ 'Undocumented response: %s, successful: %s' % [
31
+ status_code, successful
32
+ ]
33
+ elsif successful
34
+ schema = set_additional_properties_false_on(response_parameters.dup)
35
+ JSON::Validator.validate!(schema, stringify_keys(params))
36
+ else
37
+ true
38
+ end
39
+ end
40
+
41
+ def verb
42
+ @verb ||= endpoint_path.match(/([A-Z]*)\.fdoc$/)[1]
43
+ end
44
+
45
+ def path
46
+ @path ||= endpoint_path.
47
+ gsub(service.service_dir, "").
48
+ match(/\/?(.*)[-\/][A-Z]+\.fdoc/)[1]
49
+ end
50
+
51
+ # properties
52
+
53
+ def deprecated?
54
+ @schema["deprecated"]
55
+ end
56
+
57
+ def description
58
+ @schema["description"]
59
+ end
60
+
61
+ def request_parameters
62
+ @schema["requestParameters"] ||= {}
63
+ end
64
+
65
+ def response_parameters
66
+ @schema["responseParameters"] ||= {}
67
+ end
68
+
69
+ def response_codes
70
+ @schema["responseCodes"] ||= []
71
+ end
72
+
73
+ protected
74
+
75
+ # default additionalProperties on objects to false
76
+ # create a copy, so we don't mutate the input
77
+ def set_additional_properties_false_on(value)
78
+ if value.kind_of? Hash
79
+ copy = value.dup
80
+ if value["type"] == "object" || value.has_key?("properties")
81
+ copy["additionalProperties"] ||= false
82
+ end
83
+ value.each do |key, hash_val|
84
+ unless key == "additionalProperties"
85
+ copy[key] = set_additional_properties_false_on(hash_val)
86
+ end
87
+ end
88
+ copy
89
+ elsif value.kind_of? Array
90
+ copy = value.map do |arr_val|
91
+ set_additional_properties_false_on(arr_val)
92
+ end
93
+ else
94
+ value
95
+ end
96
+ end
97
+
98
+ def stringify_keys(obj)
99
+ case obj
100
+ when Hash
101
+ result = {}
102
+ obj.each do |k, v|
103
+ result[k.to_s] = stringify_keys(v)
104
+ end
105
+ result
106
+ when Array then obj.map { |v| stringify_keys(v) }
107
+ else obj
108
+ end
109
+ end
110
+ end
@@ -0,0 +1,132 @@
1
+ # EndpointScaffolds aggregate input to guess at the structure of an API
2
+ # endpoint. The #consume_* methods can modify the structure of the
3
+ # in-memory endpoint, to save the results to the file system, call #persist!
4
+ class Fdoc::EndpointScaffold < Fdoc::Endpoint
5
+ def initialize(endpoint_path, service=Fdoc::Service::DefaultService)
6
+ if File.exist?(endpoint_path)
7
+ super
8
+ else
9
+ @endpoint_path = endpoint_path
10
+ @schema = {
11
+ "description" => "???",
12
+ "responseCodes" => []
13
+ }
14
+ @service = service
15
+ end
16
+ end
17
+
18
+ def persist!
19
+ dirname = File.dirname(@endpoint_path)
20
+ Dir.mkdir(dirname) unless File.directory?(dirname)
21
+
22
+ File.open(@endpoint_path, "w") do |file|
23
+ YAML.dump(@schema, file)
24
+ end
25
+ end
26
+
27
+ def consume_request(params, successful = true)
28
+ scaffold_schema(request_parameters, stringify_keys(params), {
29
+ :root_object => true
30
+ })
31
+ end
32
+
33
+ def consume_response(params, status_code, successful=true)
34
+ if successful
35
+ scaffold_schema(response_parameters, stringify_keys(params), {
36
+ :root_object => true
37
+ })
38
+ end
39
+
40
+ response_code = response_codes.find do
41
+ |rc| rc["status"] == status_code && rc["successful"] == successful
42
+ end
43
+
44
+ if !response_code
45
+ response_codes << {
46
+ "status" => status_code,
47
+ "successful" => successful,
48
+ "description" => "???"
49
+ }
50
+ end
51
+ end
52
+
53
+ protected
54
+
55
+ def scaffold_schema(schema, params, options = {:root_object => false})
56
+ unless options[:root_object]
57
+ schema["description"] ||= "???"
58
+ schema["required"] = "???" unless schema.has_key?("required")
59
+ end
60
+
61
+ if params.kind_of? Hash
62
+ scaffold_hash(schema, params, options)
63
+ elsif params.kind_of? Array
64
+ scaffold_array(schema, params, options)
65
+ else
66
+ scaffold_atom(schema, params, options)
67
+ end
68
+ end
69
+
70
+ def scaffold_hash(schema, params, options = {})
71
+ schema["type"] ||= "object" unless options[:root_object]
72
+ schema["properties"] ||= {}
73
+
74
+ params.each do |key, value|
75
+ unless schema[key]
76
+ schema["properties"][key] ||= {}
77
+ sub_options = options.merge(:root_object => false)
78
+ scaffold_schema(schema["properties"][key], value, sub_options)
79
+ end
80
+ end
81
+ end
82
+
83
+ def scaffold_array(schema, params, options = {})
84
+ schema["type"] ||= "array"
85
+ schema["items"] ||= {}
86
+ params.each do |arr_value|
87
+ sub_options = options.merge(:root_object => false)
88
+ scaffold_schema(schema["items"], arr_value, options.merge(sub_options))
89
+ end
90
+ end
91
+
92
+ def scaffold_atom(schema, params, options = {})
93
+ value = params
94
+ schema["type"] ||= guess_type(params)
95
+ if format = guess_format(params)
96
+ schema["format"] ||= format
97
+ end
98
+ schema["example"] ||= value
99
+ end
100
+
101
+ def guess_type(value)
102
+ in_type = value.class.to_s
103
+ type_map = {
104
+ "Fixnum" => "integer",
105
+ "Float" => "number",
106
+ "Hash" => "object",
107
+ "Time" => "string",
108
+ "TrueClass" => "boolean",
109
+ "FalseClass" => "boolean",
110
+ "NilClass" => "null"
111
+ }
112
+ type_map[in_type] || in_type.downcase
113
+ end
114
+
115
+ def guess_format(value)
116
+ if value.kind_of? Time
117
+ "date-time"
118
+ elsif value.kind_of? String
119
+ if value.start_with? "http://"
120
+ "uri"
121
+ elsif value.match(/\#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?\b/)
122
+ "color"
123
+ else
124
+ begin
125
+ "date-time" if Time.iso8601(value)
126
+ rescue
127
+ nil
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end