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

Sign up to get free protection for your applications and to get access to all the features.
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