praxis 2.0.pre.4 → 2.0.pre.5

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA1:
3
- metadata.gz: fa827ff7e042719b8976def88a05ab420fa05eb5
4
- data.tar.gz: a3c09e65e43ea6d5ef508ca9be649552b271b305
3
+ metadata.gz: 05fc37d45d8686251eb9691f05b7c9cdcfb3a8fe
4
+ data.tar.gz: fd5b9c01c4eab9994319eedf5cae50957b8dce06
5
5
  SHA512:
6
- metadata.gz: 9055b2bb74579f7b0bfb6b2823df6b03dc863e30945314aa7d2251326ad1e4c7bf9a2213b24251ca8334e3e00bca5371b4fbc7d7199e7829ca4f625c9f1f0a97
7
- data.tar.gz: e458dcf8e0d69e5df81ca9a2a1bc62250891ac788f7cfd2dbc9e08a76193bcf797d099799f83cfbdc0c2010e3fe3b23bef917efc036a80f95029f0437904f847
6
+ metadata.gz: d8c4a7698ce165605a1c3a59c7fd014490b86a2bd4a27b2814e05dd1e9fa87d268a88dbd549239a07707f18b9c0a2fa350f45725686a50ef0777e1a32a11a7dc
7
+ data.tar.gz: 1a93f51757421d516ee80eb0d38101c6f0eff7d9cd9483ebb4fb44634342edf88e5ff7b16b49676cb696e7653843c1a69b7d505eaa4bde954e0bb7ae60b7f13e
@@ -1,6 +1,10 @@
1
1
  # Praxis Changelog
2
2
 
3
- ## next
3
+ ## 2.0.pre.5
4
+
5
+ - Added support for OpenAPI 3.x document generation. Consider this in Beta state, although it is fairly close to feature complete.
6
+
7
+ ## 2.0.pre.4
4
8
  - Reworked the field selection DB query generation to support full tree of eager loaded dependencies
5
9
  - Built support for both ActiveRecord and Sequel gems
6
10
  - Selected DB fields will include/map the defined resource properties and will always include any necessary fields on both sides of the joins for the given associations.
@@ -51,6 +51,7 @@ module Praxis
51
51
  module Docs
52
52
  autoload :Generator, 'praxis/docs/generator'
53
53
  autoload :LinkBuilder, 'praxis/docs/link_builder'
54
+ autoload :OpenApiGenerator, 'praxis/docs/open_api_generator'
54
55
  end
55
56
 
56
57
  # types
@@ -15,6 +15,19 @@ module Praxis
15
15
  end
16
16
  end
17
17
 
18
+ # Allow any custom method to get/set any value
19
+ def method_missing(name, val=nil)
20
+ if val.nil?
21
+ get(name)
22
+ else
23
+ set(name, val)
24
+ end
25
+ end
26
+
27
+ def respond_to_missing?(*)
28
+ true
29
+ end
30
+
18
31
  def get(k)
19
32
  return @data[k] if @data.key?(k)
20
33
  return @global_info.get(k) if @global_info
@@ -41,6 +54,14 @@ module Praxis
41
54
  end
42
55
  end
43
56
 
57
+ def logo_url(val=nil)
58
+ if val.nil?
59
+ get(:logo_url)
60
+ else
61
+ set(:logo_url, val)
62
+ end
63
+ end
64
+
44
65
  def description(val=nil)
45
66
  if val.nil?
46
67
  get(:description)
@@ -120,12 +120,7 @@ module Praxis
120
120
  File.open(filename, 'w') {|f| f.write(JSON.pretty_generate(data))}
121
121
  end
122
122
 
123
- def write_version_file( version )
124
- version_info = infos_by_version[version]
125
- # Hack, let's "inherit/copy" all traits of a version from the global definition
126
- # Eventually traits should be defined for a version (and inheritable from global) so we'll emulate that here
127
- version_info[:traits] = infos_by_version[:traits]
128
- dumped_resources = dump_resources( resources_by_version[version] )
123
+ def scan_types_for_version(version, dumped_resources)
129
124
  found_media_types = resources_by_version[version].select{|r| r.media_type}.collect {|r| r.media_type.describe }
130
125
 
131
126
  # We'll start by processing the rendered mediatypes
@@ -150,7 +145,17 @@ module Praxis
150
145
  processed_types += newfound
151
146
  newfound = scan_dump_for_types( dumped, processed_types )
152
147
  end
148
+ processed_types
149
+ end
153
150
 
151
+ def write_version_file( version )
152
+ version_info = infos_by_version[version]
153
+ # Hack, let's "inherit/copy" all traits of a version from the global definition
154
+ # Eventually traits should be defined for a version (and inheritable from global) so we'll emulate that here
155
+ version_info[:traits] = infos_by_version[:traits]
156
+
157
+ dumped_resources = dump_resources( resources_by_version[version] )
158
+ processed_types = scan_types_for_version(version, dumped_resources)
154
159
  dumped_schemas = dump_schemas( processed_types )
155
160
  full_data = {
156
161
  info: version_info[:info],
@@ -0,0 +1,255 @@
1
+ require_relative 'openapi/info_object.rb'
2
+ require_relative 'openapi/server_object.rb'
3
+ require_relative 'openapi/paths_object.rb'
4
+ require_relative 'openapi/tag_object.rb'
5
+
6
+ module Praxis
7
+ module Docs
8
+
9
+ class OpenApiGenerator < Generator
10
+ API_DOCS_DIRNAME = 'docs/openapi'
11
+
12
+ # substitutes ":params_like_so" for {params_like_so}
13
+ def self.templatize_url( string )
14
+ Mustermann.new(string).to_templates.first
15
+ end
16
+
17
+ def save!
18
+ initialize_directories
19
+ # Restrict the versions listed in the index file to the ones for which we have at least 1 resource
20
+ write_index_file( for_versions: resources_by_version.keys )
21
+ resources_by_version.keys.each do |version|
22
+ write_version_file(version)
23
+ end
24
+ end
25
+
26
+ def initialize(root)
27
+ require 'yaml'
28
+ @root = root
29
+ @resources_by_version = Hash.new do |h,k|
30
+ h[k] = Set.new
31
+ end
32
+
33
+ @infos = ApiDefinition.instance.infos
34
+ collect_resources
35
+ collect_types
36
+ end
37
+
38
+ private
39
+
40
+ def write_index_file( for_versions: )
41
+ # TODO. create a simple html file that can link to the individual versions available
42
+ end
43
+
44
+ def write_version_file( version )
45
+ # version_info = infos_by_version[version]
46
+ # # Hack, let's "inherit/copy" all traits of a version from the global definition
47
+ # # Eventually traits should be defined for a version (and inheritable from global) so we'll emulate that here
48
+ # version_info[:traits] = infos_by_version[:traits]
49
+ dumped_resources = dump_resources( resources_by_version[version] )
50
+ processed_types = scan_types_for_version(version, dumped_resources)
51
+
52
+ # Here we have:
53
+ # processed types: which includes mediatypes and normal types...real classes
54
+ # processed resources for this version: resources_by_version[version]
55
+
56
+ info_object = OpenApi::InfoObject.new(version: version, api_definition_info: @infos[version])
57
+ # We only support a server in Praxis ... so we'll use the base path
58
+ server_object = OpenApi::ServerObject.new( url: @infos[version].base_path )
59
+
60
+ paths_object = OpenApi::PathsObject.new( resources: resources_by_version[version])
61
+
62
+ full_data = {
63
+ openapi: "3.0.2",
64
+ info: info_object.dump,
65
+ servers: [server_object.dump],
66
+ paths: paths_object.dump,
67
+ # responses: {}, #TODO!! what do we get here? the templates?...need to transform to "Responses Definitions Object"
68
+ # securityDefinitions: {}, # NOTE: No security definitions in Praxis
69
+ # security: [], # NOTE: No security definitions in Praxis
70
+ }
71
+
72
+ # Create the top level tags by:
73
+ # 1- First adding all the resource display names (and descriptions)
74
+ tags_for_resources = resources_by_version[version].collect do |resource|
75
+ OpenApi::TagObject.new(name: resource.display_name, description: resource.description ).dump
76
+ end
77
+ full_data[:tags] = tags_for_resources
78
+ # 2- Then adding all of the top level traits but marking them special with the x-traitTag (of Redoc)
79
+ tags_for_traits = (ApiDefinition.instance.traits).collect do |name, info|
80
+ OpenApi::TagObject.new(name: name, description: info.description).dump.merge(:'x-traitTag' => true)
81
+ end
82
+ unless tags_for_traits.empty?
83
+ full_data[:tags] = full_data[:tags] + tags_for_traits
84
+ end
85
+
86
+ # Include only MTs (i.e., not custom types or simple types...)
87
+ component_schemas = reusable_schema_objects(processed_types.select{|t| t < Praxis::MediaType})
88
+
89
+ # 3- Then adding all of the top level Mediatypes...so we can present them at the bottom, otherwise they don't show
90
+ tags_for_mts = component_schemas.map do |(name, info)|
91
+ special_redoc_anchor = "<SchemaDefinition schemaRef=\"#/components/schemas/#{name}\" showReadOnly={true} showWriteOnly={true} />"
92
+ guessed_display = name.split('-').last # TODO!!!the informational hash does not seem to come with the "description" value set...hmm
93
+ OpenApi::TagObject.new(name: name, description: special_redoc_anchor).dump.merge(:'x-displayName' => guessed_display)
94
+ end
95
+ unless tags_for_mts.empty?
96
+ full_data[:tags] = full_data[:tags] + tags_for_mts
97
+ end
98
+
99
+ # Include all the reusable schemas in the components hash
100
+ full_data[:components] = {
101
+ schemas: component_schemas
102
+ }
103
+
104
+ # REDOC specific grouping of sidebar
105
+ resource_tags = { name: 'Resources', tags: tags_for_resources.map{|t| t[:name]} }
106
+ schema_tags = { name: 'Models', tags: tags_for_mts.map{|t| t[:name]} }
107
+ full_data['x-tagGroups'] = [resource_tags, schema_tags]
108
+
109
+ # if parameter_object = convert_to_parameter_object( version_info[:info][:base_params] )
110
+ # full_data[:parameters] = parameter_object
111
+ # end
112
+ #puts JSON.pretty_generate( full_data )
113
+ # Write the file
114
+ version_file = ( version == "n/a" ? "unversioned" : version )
115
+ filename = File.join(doc_root_dir, version_file, 'openapi')
116
+
117
+ puts "Generating Open API file : #{filename} (json and yml) "
118
+ json_data = JSON.pretty_generate(full_data)
119
+ File.open(filename+".json", 'w') {|f| f.write(json_data)}
120
+ converted_full_data = JSON.parse( json_data ) # So symbols disappear
121
+ File.open(filename+".yml", 'w') {|f| f.write(YAML.dump(converted_full_data))}
122
+
123
+ html =<<-EOB
124
+ <!DOCTYPE html>
125
+ <html>
126
+ <head>
127
+ <title>ReDoc</title>
128
+ <!-- needed for adaptive design -->
129
+ <meta charset="utf-8"/>
130
+ <meta name="viewport" content="width=device-width, initial-scale=1">
131
+ <link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
132
+
133
+ <!--
134
+ ReDoc doesn't change outer page styles
135
+ -->
136
+ <style>
137
+ body {
138
+ margin: 0;
139
+ padding: 0;
140
+ }
141
+ </style>
142
+ </head>
143
+ <body>
144
+ <redoc spec-url='http://localhost:9090/#{version_file}/openapi.json'></redoc>
145
+ <script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
146
+ </body>
147
+ </html>
148
+ EOB
149
+ html_file = File.join(doc_root_dir, version_file, 'index.html')
150
+ File.write(html_file, html)
151
+ end
152
+
153
+ def initialize_directories
154
+ @doc_root_dir = File.join(@root, API_DOCS_DIRNAME)
155
+
156
+ # remove previous data (and reset the directory)
157
+ FileUtils.rm_rf @doc_root_dir if File.exists?(@doc_root_dir)
158
+ FileUtils.mkdir_p @doc_root_dir unless File.exists? @doc_root_dir
159
+ resources_by_version.keys.each do |version|
160
+ FileUtils.mkdir_p @doc_root_dir + '/' + version
161
+ end
162
+ FileUtils.mkdir_p @doc_root_dir + '/unversioned'
163
+ end
164
+
165
+ def normalize_media_types( mtis )
166
+ mtis.collect do |mti|
167
+ MediaTypeIdentifier.load(mti).to_s
168
+ end
169
+ end
170
+
171
+ def reusable_schema_objects(types)
172
+ types.each_with_object({}) do |(type), accum|
173
+ the_type = \
174
+ if type.respond_to? :as_json_schema
175
+ type
176
+ else # If it is a blueprint ... for now, it'd be through the attribute
177
+ type.attribute
178
+ end
179
+ accum[type.id] = the_type.as_json_schema(shallow: false)
180
+ end
181
+ end
182
+
183
+ def convert_to_parameter_object( params )
184
+ # TODO!! actually convert each of them
185
+ puts "TODO! convert to parameter object"
186
+ params
187
+ end
188
+
189
+ def convert_traits_to_tags( traits )
190
+ traits.collect do |name, info|
191
+ { name: name, description: info[:description] }
192
+ end
193
+ end
194
+
195
+
196
+ def dump_responses_object( responses )
197
+ responses.each_with_object({}) do |(name, info), hash|
198
+ data = { description: info[:description] || "" }
199
+ if payload = info[:payload]
200
+ body_type= payload[:id]
201
+ raise "WAIT! response payload doesn't have an existing id for the schema!!! (do an if, and describe it if so)" unless body_type
202
+ data[:schema] = {"$ref" => "#/definitions/#{body_type}" }
203
+ end
204
+
205
+ # data[:schema] = ???TODO!!
206
+ if headers_object = dump_response_headers_object( info[:headers] )
207
+ data[:headers] = headers_object
208
+ end
209
+ if info[:payload] && ( examples_object = dump_response_examples_object( info[:payload][:examples] ) )
210
+ data[:examples] = examples_object
211
+ end
212
+ hash[info[:status]] = data
213
+ end
214
+ end
215
+ # def dump_response_headers_object( headers )
216
+ # puts "WARNING!! Finish this. It seems that headers for responses are never set in the hash??"
217
+ # unless headers.empty?
218
+ # binding.pry
219
+ # puts headers
220
+ # end
221
+ # end
222
+
223
+ def dump_response_examples_object( examples )
224
+ examples.each_with_object({}) do |(name, info), hash|
225
+ hash[info[:content_type]] = info[:body]
226
+ end
227
+ end
228
+
229
+
230
+ def dump_resources( resources )
231
+ resources.each_with_object({}) do |r, hash|
232
+ # Do not report undocumentable resources
233
+ next if r.metadata[:doc_visibility] == :none
234
+ context = [r.id]
235
+ resource_description = r.describe(context: context)
236
+
237
+ # strip actions with doc_visibility of :none
238
+ resource_description[:actions].reject! { |a| a[:metadata][:doc_visibility] == :none }
239
+
240
+ # Go through the params/payload of each action and augment them by
241
+ # adding a generated example (then stick it into the description hash)
242
+ r.actions.each do |action_name, action|
243
+ # skip actions with doc_visibility of :none
244
+ next if action.metadata[:doc_visibility] == :none
245
+
246
+ action_description = resource_description[:actions].find {|a| a[:name] == action_name }
247
+ end
248
+
249
+ hash[r.id] = resource_description
250
+ end
251
+ end
252
+
253
+ end
254
+ end
255
+ end
@@ -0,0 +1,31 @@
1
+ module Praxis
2
+ module Docs
3
+ module OpenApi
4
+ class InfoObject
5
+ attr_reader :info, :version
6
+ def initialize(version: , api_definition_info: )
7
+ @version = version
8
+ @info = api_definition_info
9
+ raise "OpenApi docs require a 'Title' for your API." unless info.title
10
+ end
11
+
12
+ def dump
13
+ data ={
14
+ title: info.title,
15
+ description: info.description,
16
+ termsOfService: info.termsOfService,
17
+ contact: info.contact,
18
+ license: info.license,
19
+ version: version,
20
+ :'x-name' => info.name,
21
+ :'x-logo' => {
22
+ url: info.logo_url,
23
+ backgroundColor: "#FFFFFF",
24
+ altText: info.title
25
+ }
26
+ }
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,59 @@
1
+ module Praxis
2
+ module Docs
3
+ module OpenApi
4
+ class MediaTypeObject
5
+ attr_reader :schema, :example
6
+ def initialize(schema:, example:)
7
+ @schema = schema
8
+ @example = example
9
+ end
10
+
11
+ def dump
12
+ {
13
+ schema: schema,
14
+ example: example,
15
+ # encoding: TODO SUPPORT IT maybe be great/necessary for multipart
16
+ }
17
+ end
18
+
19
+ # Helper to create the typical content attribute for responses and request bodies
20
+ def self.create_content_attribute_helper(type: , example_payload:, example_handlers: nil)
21
+ # Will produce 1 example encoded with a given handler (and marking it with the given content type)
22
+ unless example_handlers
23
+ example_handlers = [ {'application/json' => 'json' } ]
24
+ end
25
+ # NOTE2: we should just create a $ref here unless it's an anon mediatype...
26
+ return {} if type.is_a? SimpleMediaType # NOTE: skip if it's a SimpleMediaType?? ... is that correct?
27
+
28
+ the_schema = if type.anonymous? || ! (type < Praxis::MediaType) # Avoid referencing custom/simple Types? (i.e., just MTs)
29
+ SchemaObject.new(info: type).dump_schema
30
+ else
31
+ { '$ref': "#/components/schemas/#{type.id}" }
32
+ end
33
+
34
+ if example_payload
35
+ examples_by_content_type = {}
36
+ rendered_payload = example_payload.dump
37
+
38
+ example_handlers.each do |spec|
39
+ content_type, handler_name = spec.first
40
+ handler = Praxis::Application.instance.handlers[handler_name]
41
+ # ReDoc is not happy to display json generated outputs when served as JSON...wtf?
42
+ generated = handler.generate(rendered_payload)
43
+ final = ( handler_name == 'json') ? JSON.parse(generated) : generated
44
+ examples_by_content_type[content_type] = final
45
+ end
46
+ end
47
+
48
+ # Key string (of MT) , value MTObject
49
+ content_hash = examples_by_content_type.each_with_object({}) do |(content_type, example_hash),accum|
50
+ accum[content_type] = MediaTypeObject.new(
51
+ schema: the_schema, # Every MT will have the same exact type..oh well .. maybe a REF?
52
+ example: example_hash,
53
+ ).dump
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,40 @@
1
+ require_relative 'parameter_object'
2
+ require_relative 'request_body_object'
3
+ require_relative 'responses_object'
4
+
5
+ module Praxis
6
+ module Docs
7
+ module OpenApi
8
+ class OperationObject
9
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operation-object
10
+ attr_reader :id, :url, :action, :tags
11
+ def initialize(id:, url:, action:, tags:)
12
+ @id = id
13
+ @url = url
14
+ @action = action
15
+ @tags = tags
16
+ end
17
+
18
+ def dump
19
+ all_parameters = ParameterObject.process_parameters(action)
20
+ all_tags = tags + action.traits
21
+ h = {
22
+ summary: action.name.to_s,
23
+ description: action.description,
24
+ #externalDocs: {}, # TODO/FIXME
25
+ operationId: id,
26
+ responses: ResponsesObject.new(responses: action.responses).dump,
27
+ # callbacks
28
+ # deprecated: false
29
+ # security: [{}]
30
+ # servers: [{}]
31
+ }
32
+ h[:tags] = all_tags.uniq unless all_tags.empty?
33
+ h[:parameters] = all_parameters unless all_parameters.empty?
34
+ h[:requestBody] = RequestBodyObject.new(attribute: action.payload ).dump if action.payload
35
+ h
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,69 @@
1
+ require_relative 'schema_object'
2
+
3
+ module Praxis
4
+ module Docs
5
+ module OpenApi
6
+ class ParameterObject
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#parameter-object
8
+ attr_reader :location, :name, :is_required, :info
9
+ def initialize(location: , name:, is_required:, info:)
10
+ @location = location
11
+ @name = name
12
+ @info = info
13
+ @is_required = is_required
14
+ end
15
+
16
+ def dump
17
+ # Fixed fields
18
+ h = { name: name, in: location }
19
+ h[:description] = info.options[:description] if info.options[:description]
20
+ h[:required] = is_required if is_required
21
+ # h[:deprecated] = false
22
+ # h[:allowEmptyValue] ??? TODO: support in Praxis
23
+
24
+ # Other supported attributes
25
+ # style
26
+ # explode
27
+ # allowReserved
28
+
29
+ # Now merge the rest schema and example
30
+ # schema
31
+ # example
32
+ # examples (Example and Examples are mutually exclusive)
33
+ schema = SchemaObject.new(info: info)
34
+ h[:schema] = schema.dump_schema
35
+ # Note: we do not support the 'content' key...we always use schema
36
+ h[:example] = schema.dump_example
37
+ h
38
+ end
39
+
40
+ def self.process_parameters( action )
41
+ output = []
42
+ # An array, with one hash per param inside
43
+ if action.headers
44
+ (action.headers.attributes||{}).each_with_object(output) do |(name, info), out|
45
+ out << ParameterObject.new( location: 'header', name: name, is_required: info.options[:required], info: info ).dump
46
+ end
47
+ end
48
+
49
+ if action.params
50
+ route_params = \
51
+ if action.primary_route.nil?
52
+ warn "Warning: No routes defined for action #{action.name}"
53
+ []
54
+ else
55
+ action.primary_route.path.named_captures.keys.collect(&:to_sym)
56
+ end
57
+ (action.params.attributes||{}).each_with_object(output) do |(name, info), out|
58
+ in_type = route_params.include?(name) ? :path : :query
59
+ is_required = (in_type == :path ) ? true : info.options[:required]
60
+ out << ParameterObject.new( location: in_type, name: name, is_required: is_required, info: info ).dump
61
+ end
62
+ end
63
+
64
+ output
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,58 @@
1
+ require_relative 'operation_object.rb'
2
+ module Praxis
3
+ module Docs
4
+ module OpenApi
5
+ class PathsObject
6
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#paths-object
7
+ attr_reader :resources, :paths
8
+ def initialize(resources:)
9
+ @resources = resources
10
+ # A hash with keys of paths, and values of hash
11
+ # where the subhash has verb keys and path_items as values
12
+ # {
13
+ # "/pets": {
14
+ # "get": {...},
15
+ # "post": { ...}
16
+ # "/humans": {
17
+ # "get": {...},
18
+ @paths = Hash.new {|h,k| h[k] = {} }
19
+ end
20
+
21
+
22
+ def dump
23
+ resources.each do |resource|
24
+ compute_resource_paths( resource )
25
+ end
26
+ paths
27
+ end
28
+
29
+ def compute_resource_paths( resource )
30
+ id = resource.id
31
+ # fill in the paths hash with a key for each path for each action/route
32
+ resource.actions.each do |action_name, action|
33
+ params_example = action.params ? action.params.example(nil) : nil
34
+ urls = action.routes.collect do |route|
35
+ ActionDefinition.url_description(route: route, params: action.params, params_example: params_example)
36
+ end.compact
37
+ urls.each do |url|
38
+ verb = url[:verb].downcase
39
+ templetized_path = OpenApiGenerator.templatize_url(url[:path])
40
+ path_entry = paths[templetized_path]
41
+ # Let's fill in verb stuff within the working hash
42
+ raise "VERB #{_verb} already defined for #{id}!?!?!" if path_entry[verb]
43
+
44
+ action_uid = "action-#{action_name}-#{id}"
45
+ # Add a tag matching the resource name (hoping all actions of a resource are grouped)
46
+ action_tags = [resource.display_name]
47
+ path_entry[verb] = OperationObject.new( id: action_uid, url: url, action: action, tags: action_tags).dump
48
+ end
49
+ end
50
+ # For each path, we can further annotate with
51
+ # servers
52
+ # parameters
53
+ # But we don't have that concept in praxis
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,51 @@
1
+ require_relative 'schema_object'
2
+
3
+ module Praxis
4
+ module Docs
5
+ module OpenApi
6
+ class RequestBodyObject
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#request-body-object
8
+ attr_reader :attribute
9
+ def initialize(attribute:)
10
+ @attribute = attribute
11
+ end
12
+
13
+ def dump
14
+ h = {}
15
+ h[:description] = attribute.options[:description] if attribute.options[:description]
16
+ h[:required] = attribute.options[:required] || false
17
+
18
+ # OpenApi wants a set of bodies per MediaType/Content-Type
19
+ # For us there's really only one schema (regardless of encoding)...
20
+ # so we'll show all the supported MTs...but repeating the schema
21
+ #dumped_schema = SchemaObject.new(info: attribute).dump_schema
22
+
23
+ example_handlers = if attribute.type < Praxis::Types::MultipartArray
24
+ ident = MediaTypeIdentifier.load('multipart/form-data')
25
+ [{ident.to_s => 'plain'}] # Multipart content type, but with the plain renderer (so there's no modification)
26
+ else
27
+ # TODO: We could run it through other handlers I guess...if they're registered
28
+ [{'application/json' => 'json'}]
29
+ end
30
+
31
+ h[:content] = MediaTypeObject.create_content_attribute_helper(type: attribute.type,
32
+ example_payload: attribute.example(nil),
33
+ example_handlers: example_handlers)
34
+ # # Key string (of MT) , value MTObject
35
+ # content_hash = info[:examples].each_with_object({}) do |(handler, example_hash),accum|
36
+ # content_type = example_hash[:content_type]
37
+ # accum[content_type] = MediaTypeObject.new(
38
+ # schema: dumped_schema, # Every MT will have the same exact type..oh well
39
+ # example: info[:examples][handler][:body],
40
+ # ).dump
41
+ # end
42
+ # # TODO! Handle Multipart types! they look like arrays now in the schema...etc
43
+ # h[:content] = content_hash
44
+ h
45
+ end
46
+
47
+
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,63 @@
1
+ require_relative 'media_type_object'
2
+
3
+ module Praxis
4
+ module Docs
5
+ module OpenApi
6
+ class ResponseObject
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#response-object
8
+ attr_reader :info
9
+ def initialize(info:)
10
+ @info = info
11
+ default_handlers = ApiDefinition.instance.info.produces
12
+ @output_handlers = Praxis::Application.instance.handlers.select do |k,v|
13
+ default_handlers.include?(k)
14
+ end
15
+ end
16
+
17
+ def dump_response_headers_object( headers )
18
+ headers.each_with_object({}) do |(name,data),accum|
19
+ # data is a hash with :value and :type keys
20
+ # How did we say in that must match a value in json schema again??
21
+ accum[name] = {
22
+ schema: SchemaObject.new(info: data[:type])
23
+ # allowed values: [ data[:value] ] ??? is this the right json schema way?
24
+ }
25
+ end
26
+ end
27
+
28
+ def dump
29
+ data = {
30
+ description: info.description || ''
31
+ }
32
+ if headers_object = dump_response_headers_object( info.headers )
33
+ data[:headers] = headers_object
34
+ end
35
+
36
+ if info.media_type
37
+
38
+ identifier = MediaTypeIdentifier.load(info.media_type.identifier)
39
+ example_handlers = @output_handlers.each_with_object([]) do |(name, _handler), accum|
40
+ accum.push({ (identifier + name).to_s => name})
41
+ end
42
+ data[:content] = MediaTypeObject.create_content_attribute_helper(
43
+ type: info.media_type,
44
+ example_payload: info.example(nil),
45
+ example_handlers: example_handlers)
46
+ end
47
+
48
+ # if payload = info[:payload]
49
+ # body_type= payload[:id]
50
+ # raise "WAIT! response payload doesn't have an existing id for the schema!!! (do an if, and describe it if so)" unless body_type
51
+ # data[:schema] = {"$ref" => "#/definitions/#{body_type}" }
52
+ # end
53
+
54
+
55
+ # TODO: we do not support 'links'
56
+ data
57
+ end
58
+
59
+
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,44 @@
1
+ require_relative 'response_object'
2
+
3
+ module Praxis
4
+ module Docs
5
+ module OpenApi
6
+ class ResponsesObject
7
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#responses-object
8
+ attr_reader :responses
9
+ def initialize(responses:)
10
+ @responses = responses
11
+ end
12
+
13
+
14
+ def dump
15
+ # {
16
+ # "200": {
17
+ # "description": "a pet to be returned",
18
+ # "content": {
19
+ # "application/json": {
20
+ # "schema": {
21
+ # type: :object
22
+ # }
23
+ # }
24
+ # }
25
+ # },
26
+ # "default": {
27
+ # "description": "Unexpected error",
28
+ # "content": {
29
+ # "application/json": {
30
+ # "schema": {
31
+ # type: :object
32
+ # }
33
+ # }
34
+ # }
35
+ # }
36
+ # }
37
+ responses.each_with_object({}) do |(_response_name, response_definition), hash|
38
+ hash[response_definition.status.to_s] = ResponseObject.new(info: response_definition).dump
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,87 @@
1
+ module Praxis
2
+ module Docs
3
+ module OpenApi
4
+ class SchemaObject
5
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#schema-object
6
+ attr_reader :type, :attribute
7
+ def initialize(info:)
8
+ #info could be an attribute ... or a type?
9
+ if info.is_a? Attributor::Attribute
10
+ @attribute = info
11
+ else
12
+ @type = info
13
+ end
14
+ end
15
+
16
+ def dump_example
17
+ ex = \
18
+ if attribute
19
+ attribute.example
20
+ else
21
+ type.example
22
+ end
23
+ ex.respond_to?(:dump) ? ex.dump : ex
24
+ end
25
+
26
+ def dump_schema
27
+ if attribute
28
+ attribute.as_json_schema(shallow: true, example: nil)
29
+ else
30
+ type.as_json_schema(shallow: true, example: nil)
31
+ end
32
+ # # TODO: FIXME: return a generic object type if the passed info was weird.
33
+ # return { type: :object } unless info
34
+
35
+ # h = {
36
+ # #type: convert_family_to_json_type( info[:type] )
37
+ # type: info[:type]
38
+ # #TODO: format?
39
+ # }
40
+ # # required prop!!!??
41
+ # h[:default] = info[:default] if info[:default]
42
+ # h[:pattern] = info[:regexp] if info[:regexp]
43
+ # # TODO: there are other possible things we can do..maximum, minimum...etc
44
+
45
+ # if h[:type] == :array
46
+ # # FIXME: ... hack it for MultiPart arrays...where there's no member attr
47
+ # member_type = info[:type][:member_attribute]
48
+ # unless member_type
49
+ # member_type = { family: :hash}
50
+ # end
51
+ # h[:items] = SchemaObject.new(info: member_type ).dump_schema
52
+ # end
53
+ # h
54
+ rescue => e
55
+ puts "Error dumping schema #{e}"
56
+ end
57
+
58
+ def convert_family_to_json_type( praxis_type )
59
+ case praxis_type[:family].to_sym
60
+ when :string
61
+ :string
62
+ when :hash
63
+ :object
64
+ when :array #Warning! Multipart types are arrays!
65
+ :array
66
+ when :numeric
67
+ case praxis_type[:id]
68
+ when 'Attributor-Integer'
69
+ :integer
70
+ when 'Attributor-BigDecimal'
71
+ :integer
72
+ when 'Attributor-Float'
73
+ :number
74
+ end
75
+ when :temporal
76
+ :string
77
+ when :boolean
78
+ :boolean
79
+ else
80
+ raise "Unknown praxis family type: #{praxis_type[:family]}"
81
+ end
82
+ end
83
+
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,24 @@
1
+ module Praxis
2
+ module Docs
3
+ module OpenApi
4
+ class ServerObject
5
+ # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#server-object
6
+ attr_reader :url, :description, :variables
7
+ def initialize(url: , description: nil, variables: [])
8
+ @url = url
9
+ @description = description
10
+ @variables = variables
11
+ raise "OpenApi docs require a 'url' for your server object." unless url
12
+ end
13
+
14
+ def dump
15
+ result = {url: url}
16
+ result[:description] = description if description
17
+ result[:variables] = variables unless variables.empty?
18
+
19
+ result
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,21 @@
1
+ module Praxis
2
+ module Docs
3
+ module OpenApi
4
+ class TagObject
5
+ attr_reader :name, :description
6
+ def initialize(name:,description: )
7
+ @name = name
8
+ @description = description
9
+ end
10
+
11
+ def dump
12
+ {
13
+ name: name,
14
+ description: description,
15
+ #externalDocs: ???,
16
+ }
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -62,6 +62,10 @@ module Praxis
62
62
  end
63
63
  end
64
64
 
65
+ def json_schema_type
66
+ :string
67
+ end
68
+
65
69
  def add_filter(name, operators:, fuzzy:)
66
70
  components = name.to_s.split('.').map(&:to_sym)
67
71
  attribute, enclosing_type = find_filter_attribute(components, media_type)
@@ -7,6 +7,10 @@ module Praxis
7
7
  include Attributor::Type
8
8
  include Attributor::Dumpable
9
9
 
10
+ def self.json_schema_type
11
+ :string
12
+ end
13
+
10
14
  def self.native_type
11
15
  self
12
16
  end
@@ -54,6 +54,10 @@ module Praxis
54
54
  super(false,**opts) # Links must always describe attributes
55
55
  end
56
56
 
57
+ def self.json_schema_type
58
+ @attribute.type.json_schema_type
59
+ end
60
+
57
61
  def self._finalize!
58
62
  super
59
63
  if @attribute
@@ -42,10 +42,6 @@ module Praxis::Mapper
42
42
  association[:local_key_columns].each {|col| add_select(col) }
43
43
 
44
44
  node = SelectorGeneratorNode.new(associated_resource)
45
- if association[:remote_key_columns].nil?
46
- binding.pry
47
- puts association
48
- end
49
45
  unless association[:remote_key_columns].empty?
50
46
  # Make sure we add the required columns for this association to the remote model query
51
47
  fields = {} if fields == true
@@ -28,6 +28,10 @@ module Praxis
28
28
  self
29
29
  end
30
30
 
31
+ def self.json_schema_type
32
+ :object
33
+ end
34
+
31
35
  def self.example(context=nil, options:{})
32
36
  if (payload_attribute = options[:payload_attribute])
33
37
  payload = payload_attribute.example(context + ['payload'])
@@ -52,8 +56,7 @@ module Praxis
52
56
  headers_attribute: headers_attribute,
53
57
  filename_attribute: filename_attribute)
54
58
  end
55
-
56
-
59
+
57
60
  def self.describe(shallow=true, example: nil, options:{})
58
61
  hash = super(shallow, example: example)
59
62
 
@@ -163,7 +163,7 @@ module Praxis
163
163
  end
164
164
  end
165
165
 
166
- content[:payload] = payload
166
+ content[:payload] = {type: payload}
167
167
  end
168
168
 
169
169
  unless parts == nil
@@ -62,5 +62,28 @@ namespace :praxis do
62
62
  generator.save!
63
63
  end
64
64
 
65
+ desc "Generate OpenAPI 3 docs for a Praxis App"
66
+ task :openapi => [:environment] do |t, args|
67
+ require 'fileutils'
68
+
69
+ Praxis::Blueprint.caching_enabled = false
70
+ generator = Praxis::Docs::OpenApiGenerator.new(Dir.pwd)
71
+ generator.save!
72
+ end
73
+
74
+ desc "Preview (and Generate) OpenAPI 3 docs for a Praxis App"
75
+ task :openapipreview => [:openapi] do |t, args|
76
+ require 'webrick'
77
+ docs_port = 9090
78
+ root = Dir.pwd + '/docs/openapi/'
79
+ wb = Thread.new do
80
+ s = WEBrick::HTTPServer.new(:Port => docs_port, :DocumentRoot => root)
81
+ trap('INT') { s.shutdown }
82
+ s.start
83
+ end
84
+ `open http://localhost:#{docs_port}/`
85
+ wb.join
86
+ end
87
+
65
88
  end
66
89
  end
@@ -14,6 +14,16 @@ module Praxis
14
14
  hash
15
15
  end
16
16
 
17
+ def as_json_schema(**args)
18
+ the_type = @attribute && @attribute.type || member_type
19
+ the_type.as_json_schema(args)
20
+ end
21
+
22
+ def json_schema_type
23
+ the_type = @attribute && @attribute.type || member_type
24
+ the_type.json_schema_type
25
+ end
26
+
17
27
  def description(text=nil)
18
28
  @description = text if text
19
29
  @description
@@ -153,6 +153,68 @@ module Praxis
153
153
  example
154
154
  end
155
155
 
156
+ def self.json_schema_type
157
+ :object
158
+ end
159
+
160
+ # Multipart request bodies are special in OPEN API
161
+ # schema: # Request payload
162
+ # type: object
163
+ # properties: # Request parts
164
+ # id: # Part 1 (string value)
165
+ # type: string
166
+ # format: uuid
167
+ # address: # Part2 (object)
168
+ # type: object
169
+ # properties:
170
+ # street:
171
+ # type: string
172
+ # city:
173
+ # type: string
174
+ # profileImage: # Part 3 (an image)
175
+ # type: string
176
+ # format: binary
177
+ #
178
+ # NOTE: not sure if this
179
+ def self.as_openapi_request_body( attribute_options: {} )
180
+ hash = { type: json_schema_type }
181
+ opts = self.options.merge( attribute_options )
182
+ hash[:description] = opts[:description] if opts[:description]
183
+ hash[:default] = opts[:default] if opts[:default]
184
+
185
+ unless self.attributes.empty?
186
+ props = {}
187
+ encoding = {}
188
+ self.attributes.each do |part_name, part_attribute|
189
+ part_example = part_attribute.example
190
+ key_to_use = part_name.is_a?(Regexp) ? part_name.source : part_name
191
+
192
+ part_info = {}
193
+ if (payload_attribute = part_attribute.options[:payload_attribute])
194
+ props[key_to_use] = payload_attribute.as_json_schema(example: part_example.payload)
195
+ end
196
+ #{
197
+ # contentType: 'fff',
198
+ # headers: {
199
+ # custom1: 'safd'
200
+ # }
201
+ if (headers_attribute = part_attribute.options[:headers_attribute])
202
+ # Does this 'Content-Type' string check work?...can it be a symbol? what does it mean anyway?
203
+ encoding[key_to_use][:contentType] = headers_attribute['Content-Type'] if headers_attribute['Content-Type']
204
+ # TODO?rethink? ...is this correct?: att a 'headers' key with some header schemas if this part have some
205
+ encoding[key_to_use]['headers'] = headers_attribute.as_json_schema(example: part_example.headers)
206
+ end
207
+ end
208
+
209
+ hash[:properties] = props
210
+ hash[:encoding] = encoding unless encoding.empty?
211
+ end
212
+ hash
213
+ end
214
+
215
+ def self.as_json_schema( shallow: false, example: nil, attribute_options: {} )
216
+ as_openapi_request_body(attribute_options: attribute_options)
217
+ end
156
218
 
157
219
  def self.describe(shallow=true, example: nil)
158
220
  type_name = Attributor.type_name(self)
@@ -1,3 +1,3 @@
1
1
  module Praxis
2
- VERSION = '2.0.pre.4'
2
+ VERSION = '2.0.pre.5'
3
3
  end
@@ -24,8 +24,8 @@ Gem::Specification.new do |spec|
24
24
  spec.add_dependency 'mustermann', '>=1.1', '<=2'
25
25
  spec.add_dependency 'activesupport', '>= 3'
26
26
  spec.add_dependency 'mime', '~> 0'
27
- spec.add_dependency 'praxis-blueprints', '>= 3.4'
28
- spec.add_dependency 'attributor', '>= 5.4'
27
+ spec.add_dependency 'praxis-blueprints', '>= 3.5'
28
+ spec.add_dependency 'attributor', '>= 5.5'
29
29
  spec.add_dependency 'thor'
30
30
  spec.add_dependency 'terminal-table', '~> 1.4'
31
31
 
@@ -514,12 +514,11 @@ describe Praxis::ResponseDefinition do
514
514
 
515
515
  context 'for a definition with a media type' do
516
516
  let(:media_type) { Instance }
517
- subject(:payload) { output[:payload] }
517
+ subject(:payload) { output[:payload][:type] }
518
518
 
519
519
  before do
520
520
  response.media_type Instance
521
521
  end
522
-
523
522
  its([:name]) { should eq 'Instance' }
524
523
  context 'examples' do
525
524
  subject(:examples) { payload[:examples] }
@@ -571,7 +570,9 @@ describe Praxis::ResponseDefinition do
571
570
  end
572
571
 
573
572
  it{ should be_kind_of(::Hash) }
574
- its([:payload]){ should == {id: 'Praxis-SimpleMediaType', name: 'Praxis::SimpleMediaType', family: 'string', identifier: 'foobar' } }
573
+ it 'has the right type info' do
574
+ expect(subject[:payload][:type]).to match(id: 'Praxis-SimpleMediaType', name: 'Praxis::SimpleMediaType', family: 'string', identifier: 'foobar')
575
+ end
575
576
  its([:status]){ should == 200 }
576
577
  end
577
578
  context 'using a full response definition block' do
@@ -587,7 +588,9 @@ describe Praxis::ResponseDefinition do
587
588
  end
588
589
 
589
590
  it{ should be_kind_of(::Hash) }
590
- its([:payload]) { should == {id: 'Praxis-SimpleMediaType', name: 'Praxis::SimpleMediaType', family: 'string', identifier: 'custom_media'} }
591
+ it 'has the right type info' do
592
+ expect(subject[:payload][:type]).to match(id: 'Praxis-SimpleMediaType', name: 'Praxis::SimpleMediaType', family: 'string', identifier: 'custom_media')
593
+ end
591
594
  its([:status]) { should == 234 }
592
595
  end
593
596
  end
@@ -27,6 +27,12 @@ Praxis::ApiDefinition.define do
27
27
  produces 'json','xml'
28
28
  #version_with :path
29
29
  #base_path "/v:api_version"
30
+
31
+ # Custom attributes (for OpenApi, for example)
32
+ termsOfService "http://example.com/tos"
33
+ contact name: 'Joe', email: 'joe@email.com'
34
+ license name: "Apache 2.0",
35
+ url: "https://www.apache.org/licenses/LICENSE-2.0.html"
30
36
  end
31
37
 
32
38
  info '1.0' do # Applies to 1.0 version (and inherits everything else form the global one)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: praxis
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.pre.4
4
+ version: 2.0.pre.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Josep M. Blanquer
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2020-08-05 00:00:00.000000000 Z
12
+ date: 2020-08-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: rack
@@ -79,28 +79,28 @@ dependencies:
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '3.4'
82
+ version: '3.5'
83
83
  type: :runtime
84
84
  prerelease: false
85
85
  version_requirements: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
88
88
  - !ruby/object:Gem::Version
89
- version: '3.4'
89
+ version: '3.5'
90
90
  - !ruby/object:Gem::Dependency
91
91
  name: attributor
92
92
  requirement: !ruby/object:Gem::Requirement
93
93
  requirements:
94
94
  - - ">="
95
95
  - !ruby/object:Gem::Version
96
- version: '5.4'
96
+ version: '5.5'
97
97
  type: :runtime
98
98
  prerelease: false
99
99
  version_requirements: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: '5.4'
103
+ version: '5.5'
104
104
  - !ruby/object:Gem::Dependency
105
105
  name: thor
106
106
  requirement: !ruby/object:Gem::Requirement
@@ -540,6 +540,18 @@ files:
540
540
  - lib/praxis/dispatcher.rb
541
541
  - lib/praxis/docs/generator.rb
542
542
  - lib/praxis/docs/link_builder.rb
543
+ - lib/praxis/docs/open_api_generator.rb
544
+ - lib/praxis/docs/openapi/info_object.rb
545
+ - lib/praxis/docs/openapi/media_type_object.rb
546
+ - lib/praxis/docs/openapi/operation_object.rb
547
+ - lib/praxis/docs/openapi/parameter_object.rb
548
+ - lib/praxis/docs/openapi/paths_object.rb
549
+ - lib/praxis/docs/openapi/request_body_object.rb
550
+ - lib/praxis/docs/openapi/response_object.rb
551
+ - lib/praxis/docs/openapi/responses_object.rb
552
+ - lib/praxis/docs/openapi/schema_object.rb
553
+ - lib/praxis/docs/openapi/server_object.rb
554
+ - lib/praxis/docs/openapi/tag_object.rb
543
555
  - lib/praxis/error_handler.rb
544
556
  - lib/praxis/exception.rb
545
557
  - lib/praxis/exceptions/config.rb