apiwork 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 14f70185b89b359e58791101591102755c5e523fed61fe4e4cdf7e5ad3cd1615
4
- data.tar.gz: 10303ee74f360fa49fd711497dec982094cd7d3f7621d08a47c68abecf22bd3f
3
+ metadata.gz: eb5f681a0f08e4bebe2d0f5ebf4edac733f3787e4144f019bc4babfa7a7ef84a
4
+ data.tar.gz: e5d1d87115d2902295d66567c324d73ef15d01b0ac106c1fa9739b56a276aeb3
5
5
  SHA512:
6
- metadata.gz: cb799505dac690b1807a31f3c6f87d5b4893d85ba79f007966a2e6bfe19c58e2e63a0bf6bed4080de6b8c7a1aee3fbd9deab76f4fbabf8c416ff9ab9e78598b6
7
- data.tar.gz: 0bfe2378cfc17aab81dd0af3ccd1fff1e0bbafad476e97e9c71f0b2c7764485d2f02f44290a7c9015d2ada479ae2b37d00c76786369515361a424688cb1b390a
6
+ metadata.gz: bb539d87fcd3b065d643711616f7f9f5589d574cbf12bcc06c69aafe7000f6fafa4e19d7fd34cd165d2734076d3b192472a6f51459074241fabbf5dd3faece42
7
+ data.tar.gz: 692745d73d86c3f5c377ae2a7962b38efe351b1fc893df6af45bd8de025622f61257307dedcd1b6aac15df8f3112874e92c54d3b3e316ef9aaa59ef6fcca0338
data/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Apiwork
2
2
 
3
+ [![Gem Version](https://img.shields.io/gem/v/apiwork)](https://rubygems.org/gems/apiwork)
4
+ [![CI](https://github.com/skiftle/apiwork/actions/workflows/ci.yml/badge.svg)](https://github.com/skiftle/apiwork/actions/workflows/ci.yml)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE.txt)
6
+
7
+ ![OpenAPI](https://img.shields.io/badge/OpenAPI-exports-6BA539?logo=openapiinitiative&logoColor=white)
8
+ ![TypeScript](https://img.shields.io/badge/TypeScript-exports-3178C6?logo=typescript&logoColor=white)
9
+ ![Zod](https://img.shields.io/badge/Zod-exports-3068B7?logo=zod&logoColor=white)
10
+
3
11
  Typed APIs for Rails.
4
12
 
5
13
  Apiwork lets you define your API once and derive validation, serialization, querying, and typed exports from the same definition.
@@ -210,18 +210,16 @@ module Apiwork
210
210
 
211
211
  error_serializer_class = self.class.error_serializer
212
212
  error_serializer_class.new.api_types(api_class)
213
-
214
- build_error_response_body(api_class, error_serializer_class)
215
213
  end
216
214
 
217
- def register_contract(contract_class, representation_class, actions)
215
+ def register_contract(contract_class, representation_class, resource_actions: {})
218
216
  capabilities.each do |capability|
219
- capability.contract_types(contract_class, representation_class, actions)
217
+ capability.contract_types(contract_class, representation_class, resource_actions)
220
218
  end
221
219
 
222
220
  self.class.resource_serializer.new(representation_class).contract_types(contract_class)
223
221
 
224
- build_action_responses(contract_class, representation_class, actions)
222
+ build_action_responses(contract_class, representation_class, resource_actions) if resource_actions.any?
225
223
  end
226
224
 
227
225
  def apply_request_transformers(request, phase:)
@@ -250,8 +248,8 @@ module Apiwork
250
248
  )
251
249
  end
252
250
 
253
- def build_action_responses(contract_class, representation_class, actions)
254
- actions.each_value do |action|
251
+ def build_action_responses(contract_class, representation_class, resource_actions)
252
+ resource_actions.each_value do |action|
255
253
  build_action_response(contract_class, representation_class, action)
256
254
  end
257
255
  end
@@ -273,12 +271,10 @@ module Apiwork
273
271
  end
274
272
 
275
273
  def build_member_action_response(contract_class, representation_class, action, contract_action)
276
- result_wrapper = build_result_wrapper(contract_class, representation_class, action.name, :member)
277
274
  member_shape_class = self.class.member_wrapper.shape_class
278
275
  data_type = resolve_resource_data_type(representation_class)
279
276
 
280
277
  contract_action.response do |response|
281
- response.result_wrapper = result_wrapper
282
278
  response.body do |body|
283
279
  member_shape_class.apply(body, representation_class.root_key, capabilities, representation_class, :member, data_type:)
284
280
  end
@@ -286,12 +282,10 @@ module Apiwork
286
282
  end
287
283
 
288
284
  def build_collection_action_response(contract_class, representation_class, action, contract_action)
289
- result_wrapper = build_result_wrapper(contract_class, representation_class, action.name, :collection)
290
285
  collection_shape_class = self.class.collection_wrapper.shape_class
291
286
  data_type = resolve_resource_data_type(representation_class)
292
287
 
293
288
  contract_action.response do |response|
294
- response.result_wrapper = result_wrapper
295
289
  response.body do |body|
296
290
  collection_shape_class.apply(body, representation_class.root_key, capabilities, representation_class, :collection, data_type:)
297
291
  end
@@ -308,42 +302,10 @@ module Apiwork
308
302
  end
309
303
  end
310
304
 
311
- def build_result_wrapper(contract_class, representation_class, action_name, response_type)
312
- success_type_name = [action_name, 'success_response_body'].join('_').to_sym
313
-
314
- unless contract_class.type?(success_type_name)
315
- shape_class = if response_type == :collection
316
- self.class.collection_wrapper.shape_class
317
- else
318
- self.class.member_wrapper.shape_class
319
- end
320
- data_type = resolve_resource_data_type(representation_class)
321
-
322
- contract_class.object(success_type_name) do |object|
323
- shape_class.apply(object, representation_class.root_key, capabilities, representation_class, response_type, data_type:)
324
- end
325
- end
326
-
327
- { error_type: :error_response_body, success_type: contract_class.scoped_type_name(success_type_name) }
328
- end
329
-
330
305
  def resolve_resource_data_type(representation_class)
331
306
  self.class.resource_serializer.data_type.call(representation_class)
332
307
  end
333
308
 
334
- def build_error_response_body(api_class, error_serializer_class)
335
- return if api_class.type?(:error_response_body)
336
-
337
- shape_class = self.class.error_wrapper.shape_class
338
- return unless shape_class
339
-
340
- data_type = error_serializer_class.data_type
341
-
342
- api_class.object(:error_response_body) do |object|
343
- shape_class.apply(object, nil, [], nil, :error, data_type:)
344
- end
345
- end
346
-
347
309
  def run_capability_request_transformers(request, phase:)
348
310
  transformers = capability_request_transformers.select { |transformer_class| transformer_class.phase == phase }
349
311
  result = request
@@ -727,9 +727,9 @@ module Apiwork
727
727
  @built_contracts.add(contract_class)
728
728
 
729
729
  resource = @root_resource.find_resource { |resource| resource.resolve_contract_class == contract_class }
730
- actions = resource ? resource.actions : {}
730
+ resource_actions = resource ? resource.actions : {}
731
731
 
732
- adapter.register_contract(contract_class, representation_class, actions)
732
+ adapter.register_contract(contract_class, representation_class, resource_actions:)
733
733
  end
734
734
 
735
735
  def ensure_pre_pass_complete!
@@ -788,13 +788,11 @@ module Apiwork
788
788
  contract_class = resource.resolve_contract_class
789
789
  return unless contract_class
790
790
  return if @built_contracts.include?(contract_class)
791
-
792
- representation_class = contract_class.representation_class
793
- return unless representation_class
791
+ return unless contract_class.representation_class
794
792
 
795
793
  @built_contracts.add(contract_class)
796
794
 
797
- adapter.register_contract(contract_class, representation_class, resource.actions)
795
+ adapter.register_contract(contract_class, contract_class.representation_class, resource_actions: resource.actions)
798
796
  end
799
797
  end
800
798
  end
@@ -16,6 +16,28 @@ module Apiwork
16
16
  @action_name = action_name
17
17
  @query = nil
18
18
  @body = nil
19
+ @description = nil
20
+ end
21
+
22
+ # @api public
23
+ # The description for this request.
24
+ #
25
+ # Metadata included in exports.
26
+ #
27
+ # @param value [String, nil] (nil)
28
+ # The description.
29
+ # @return [String, nil]
30
+ #
31
+ # @example
32
+ # action :create do
33
+ # request do
34
+ # description 'The invoice to create'
35
+ # end
36
+ # end
37
+ def description(value = nil)
38
+ return @description if value.nil?
39
+
40
+ @description = value
19
41
  end
20
42
 
21
43
  # @api public
@@ -11,16 +11,35 @@ module Apiwork
11
11
  attr_reader :action_name,
12
12
  :contract_class
13
13
 
14
- attr_accessor :result_wrapper
15
-
16
14
  def initialize(contract_class, action_name)
17
15
  @contract_class = contract_class
18
16
  @action_name = action_name
19
17
  @body = nil
20
- @result_wrapper = nil
18
+ @description = nil
21
19
  @no_content = false
22
20
  end
23
21
 
22
+ # @api public
23
+ # The description for this response.
24
+ #
25
+ # Metadata included in exports.
26
+ #
27
+ # @param value [String, nil] (nil)
28
+ # The description.
29
+ # @return [String, nil]
30
+ #
31
+ # @example
32
+ # action :show do
33
+ # response do
34
+ # description 'Returns the invoice'
35
+ # end
36
+ # end
37
+ def description(value = nil)
38
+ return @description if value.nil?
39
+
40
+ @description = value
41
+ end
42
+
24
43
  # @api public
25
44
  # Whether this response has no content.
26
45
  #
@@ -106,7 +106,11 @@ module Apiwork
106
106
  query_params = request.query? ? build_query_parameters(request.query) : []
107
107
  all_params = path_params + query_params
108
108
  operation[:parameters] = all_params if all_params.any?
109
- operation[:requestBody] = build_request_body(request.body) if request.body?
109
+ if request.body?
110
+ request_body = build_request_body(request.body)
111
+ request_body[:description] = request.description if request.description
112
+ operation[:requestBody] = request_body
113
+ end
110
114
  elsif path_params.any?
111
115
  operation[:parameters] = path_params
112
116
  end
@@ -200,42 +204,23 @@ module Apiwork
200
204
  def build_responses(action_name, response, raises: [])
201
205
  responses = {}
202
206
 
207
+ response_description = response.description || ''
208
+
203
209
  if response.no_content?
204
- responses[:'204'] = { description: 'No content' }
210
+ responses[:'204'] = { description: response_description }
205
211
  elsif response.body
206
- body = response.body
207
-
208
- if body.union? && body.discriminator.nil?
209
- success_variant = body.variants[0]
210
- error_variant = body.variants[1]
211
-
212
- responses[:'200'] = {
213
- content: {
214
- 'application/json': {
215
- schema: map_param(success_variant),
216
- },
217
- },
218
- description: 'Successful response',
219
- }
220
-
221
- raises.each do |code|
222
- error_code = api.error_codes[code]
223
- responses[error_code.status.to_s.to_sym] = build_union_error_response(error_code.description, error_variant)
224
- end
225
- else
226
- responses[:'200'] = {
227
- content: {
228
- 'application/json': {
229
- schema: map_param(body),
230
- },
212
+ responses[:'200'] = {
213
+ content: {
214
+ 'application/json': {
215
+ schema: map_param(response.body),
231
216
  },
232
- description: 'Successful response',
233
- }
217
+ },
218
+ description: response_description,
219
+ }
234
220
 
235
- raises.each do |code|
236
- error_code = api.error_codes[code]
237
- responses[error_code.status.to_s.to_sym] = build_error_response(error_code.description)
238
- end
221
+ raises.each do |code|
222
+ error_code = api.error_codes[code]
223
+ responses[error_code.status.to_s.to_sym] = build_error_response(error_code.description)
239
224
  end
240
225
  elsif response
241
226
  responses[:'200'] = {
@@ -249,10 +234,10 @@ module Apiwork
249
234
  },
250
235
  },
251
236
  },
252
- description: 'Successful response',
237
+ description: response_description,
253
238
  }
254
239
  else
255
- responses[:'204'] = { description: 'No content' }
240
+ responses[:'204'] = { description: '' }
256
241
  end
257
242
 
258
243
  responses
@@ -280,17 +265,6 @@ module Apiwork
280
265
  }
281
266
  end
282
267
 
283
- def build_union_error_response(description, error_variant)
284
- {
285
- description:,
286
- content: {
287
- 'application/json': {
288
- schema: map_param(error_variant),
289
- },
290
- },
291
- }
292
- end
293
-
294
268
  def surface
295
269
  @surface ||= SurfaceResolver.resolve(api)
296
270
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Export
5
+ class Sorbus < Base
6
+ export_name :sorbus
7
+ output :string
8
+ file_extension '.ts'
9
+
10
+ def generate
11
+ SorbusMapper.map(self, surface)
12
+ end
13
+
14
+ private
15
+
16
+ def surface
17
+ @surface ||= SurfaceResolver.resolve(api)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Apiwork
4
+ module Export
5
+ class SorbusMapper
6
+ class << self
7
+ def map(export, surface)
8
+ new(export).map(surface)
9
+ end
10
+ end
11
+
12
+ def initialize(export)
13
+ @export = export
14
+ @zod_mapper = ZodMapper.new(export)
15
+ @type_script_mapper = TypeScriptMapper.new(export)
16
+ end
17
+
18
+ def map(surface)
19
+ @surface = surface
20
+
21
+ [
22
+ "import { z } from 'zod';",
23
+ @zod_mapper.build_enum_schemas(surface.enums).presence,
24
+ @zod_mapper.build_type_schemas(surface.types).presence,
25
+ build_typescript_types.presence,
26
+ build_contract,
27
+ ].compact.join("\n\n")
28
+ end
29
+
30
+ private
31
+
32
+ def build_typescript_types
33
+ types = @type_script_mapper.build_enum_types(@surface.enums) +
34
+ @type_script_mapper.build_type_definitions(@surface.types)
35
+
36
+ types.sort_by { |entry| entry[:name] }.map { |entry| entry[:code] }.join("\n\n")
37
+ end
38
+
39
+ def build_contract
40
+ contract = {
41
+ endpoints: build_endpoint_tree(@export.api.resources),
42
+ error: build_error_schema,
43
+ }
44
+ "export const contract = #{format_object(contract, indent: 0)} as const;"
45
+ end
46
+
47
+ def build_endpoint_tree(resources, parent_identifiers: [])
48
+ resources.each_with_object({}) do |(name, resource), tree|
49
+ resource_key = @export.transform_key(name)
50
+ identifiers = parent_identifiers + [resource.identifier]
51
+
52
+ endpoints = resource.actions.each_with_object({}) do |(action_name, action), actions|
53
+ actions[@export.transform_key(action_name)] = build_endpoint(
54
+ resource.identifier.to_sym, action_name, action
55
+ )
56
+ end
57
+
58
+ endpoints.merge!(build_endpoint_tree(resource.resources, parent_identifiers: identifiers))
59
+ tree[resource_key] = endpoints
60
+ end
61
+ end
62
+
63
+ def build_endpoint(resource_name, action_name, action)
64
+ path = transform_path(action.path)
65
+ endpoint = { path:, method: action.method.to_s.upcase }
66
+
67
+ path_params = extract_path_params(action.path)
68
+ endpoint[:pathParams] = build_path_params_schema(path_params) if path_params.any?
69
+
70
+ request = build_request(action.request)
71
+ endpoint[:request] = request if request
72
+
73
+ response = build_response(action.response)
74
+ endpoint[:response] = response if response
75
+
76
+ errors = resolve_errors(action)
77
+ endpoint[:errors] = errors if errors.any?
78
+
79
+ endpoint
80
+ end
81
+
82
+ def transform_path(path)
83
+ path.gsub(%r{(/:?)(\w+)}) do
84
+ "#{::Regexp.last_match(1)}#{@export.transform_key(::Regexp.last_match(2))}"
85
+ end
86
+ end
87
+
88
+ def extract_path_params(path)
89
+ path.scan(/:(\w+)/).flatten
90
+ end
91
+
92
+ def build_path_params_schema(params)
93
+ properties = params.map { |param| "#{@export.transform_key(param)}: z.string()" }.join(', ')
94
+ "z.object({ #{properties} })"
95
+ end
96
+
97
+ def build_request(request)
98
+ return unless request.query? || request.body?
99
+
100
+ hash = {}
101
+ hash[:query] = build_params_schema(request.query) if request.query?
102
+ hash[:body] = build_params_schema(request.body) if request.body?
103
+ hash
104
+ end
105
+
106
+ def build_response(response)
107
+ return unless response.body?
108
+
109
+ { body: @zod_mapper.map_param(response.body) }
110
+ end
111
+
112
+ def build_params_schema(params)
113
+ properties = params.sort_by { |name, _| name.to_s }.map do |name, param|
114
+ "#{@export.transform_key(name)}: #{@zod_mapper.map_field(param)}"
115
+ end.join(', ')
116
+ "z.object({ #{properties} })"
117
+ end
118
+
119
+ def build_error_schema
120
+ 'ErrorSchema'
121
+ end
122
+
123
+ def resolve_errors(action)
124
+ action.raises.map { |code| @export.api.error_codes[code].status }.sort.uniq
125
+ end
126
+
127
+ def format_object(hash, indent:)
128
+ return '{}' if hash.empty?
129
+
130
+ padding = ' ' * (indent + 1)
131
+ closing_padding = ' ' * indent
132
+
133
+ entries = hash.map do |key, value|
134
+ formatted_value = format_value(value, indent: indent + 1)
135
+ "#{padding}#{key}: #{formatted_value},"
136
+ end
137
+
138
+ "{\n#{entries.join("\n")}\n#{closing_padding}}"
139
+ end
140
+
141
+ def format_value(value, indent:)
142
+ case value
143
+ when Hash
144
+ format_object(value, indent:)
145
+ when String
146
+ if value.start_with?('z.') || value.end_with?('Schema')
147
+ value
148
+ else
149
+ "'#{value}'"
150
+ end
151
+ when Array
152
+ "[#{value.join(', ')}]"
153
+ else
154
+ value.to_s
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -56,6 +56,7 @@ module Apiwork
56
56
  action.request.query.each_value { |param| collect_types_from_param(param, type_names) }
57
57
  action.request.body.each_value { |param| collect_types_from_param(param, type_names) }
58
58
  collect_types_from_param(action.response.body, type_names) if action.response.body
59
+ type_names << :error if action.raises.any? && @api.types.key?(:error)
59
60
  end
60
61
 
61
62
  def collect_types_from_param(param, type_names)
@@ -14,9 +14,10 @@ module Apiwork
14
14
  end
15
15
 
16
16
  def map(surface)
17
- types = build_enum_types(surface) +
18
- build_type_definitions(surface) +
19
- build_action_types
17
+ types = build_enum_types(surface.enums) +
18
+ build_type_definitions(surface.types) +
19
+ build_action_types +
20
+ build_action_response_types
20
21
 
21
22
  types.sort_by { |entry| entry[:name] }.map { |entry| entry[:code] }.join("\n\n")
22
23
  end
@@ -116,18 +117,25 @@ module Apiwork
116
117
  "export type #{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)} = #{map_param(response_body_definition)};"
117
118
  end
118
119
 
119
- def build_action_response_type(resource_name, action_name, response, parent_identifiers: [])
120
- "export interface #{action_type_name(
121
- resource_name,
122
- action_name,
123
- 'Response',
124
- parent_identifiers:,
125
- )} {\n body: #{action_type_name(
126
- resource_name,
127
- action_name,
128
- 'ResponseBody',
129
- parent_identifiers:,
130
- )};\n}"
120
+ def build_action_response_type(resource_name, action_name, response, parent_identifiers: [], raises:)
121
+ type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
122
+
123
+ success_variant = if response.no_content?
124
+ '{ status: 204 }'
125
+ else
126
+ body_ref = action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)
127
+ "{ status: 200; body: #{body_ref} }"
128
+ end
129
+
130
+ error_statuses = resolve_error_statuses(raises)
131
+
132
+ if error_statuses.empty?
133
+ "export type #{type_name} = #{success_variant};"
134
+ else
135
+ error_variants = error_statuses.map { |status| "{ status: #{status}; body: #{pascal_case(:error)} }" }
136
+ all_variants = ([success_variant] + error_variants).map { |variant| " | #{variant}" }.join("\n")
137
+ "export type #{type_name} =\n#{all_variants};"
138
+ end
131
139
  end
132
140
 
133
141
  def action_type_name(resource_name, action_name, suffix, parent_identifiers: [])
@@ -251,23 +259,29 @@ module Apiwork
251
259
  end
252
260
  end
253
261
 
254
- private
262
+ def build_action_types
263
+ types = []
255
264
 
256
- def build_enum_types(surface)
257
- surface.enums.map do |name, enum|
258
- { code: build_enum_type(name, enum), name: pascal_case(name) }
259
- end
260
- end
265
+ traverse_resources do |resource|
266
+ resource_name = resource.identifier.to_sym
267
+ parent_identifiers = resource.parent_identifiers
261
268
 
262
- def build_type_definitions(surface)
263
- TypeAnalysis.topological_sort_types(surface.types.transform_values(&:to_h)).map(&:first).map do |type_name|
264
- type = surface.types[type_name]
265
- code = type.union? ? build_union_type(type_name, type) : build_interface(type_name, type)
266
- { code:, name: pascal_case(type_name) }
269
+ resource.actions.each do |action_name, action|
270
+ types.concat(build_request_types(resource_name, action_name, action.request, parent_identifiers:))
271
+
272
+ response = action.response
273
+ next unless response.body?
274
+
275
+ type_name = action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)
276
+ code = build_action_response_body_type(resource_name, action_name, response.body, parent_identifiers:)
277
+ types << { code:, name: type_name }
278
+ end
267
279
  end
280
+
281
+ types
268
282
  end
269
283
 
270
- def build_action_types
284
+ def build_action_response_types
271
285
  types = []
272
286
 
273
287
  traverse_resources do |resource|
@@ -275,14 +289,42 @@ module Apiwork
275
289
  parent_identifiers = resource.parent_identifiers
276
290
 
277
291
  resource.actions.each do |action_name, action|
278
- types.concat(build_request_types(resource_name, action_name, action.request, parent_identifiers:))
279
- types.concat(build_response_types(resource_name, action_name, action.response, parent_identifiers:))
292
+ type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
293
+ code = build_action_response_type(resource_name, action_name, action.response, parent_identifiers:, raises: action.raises)
294
+ types << { code:, name: type_name }
280
295
  end
281
296
  end
282
297
 
283
298
  types
284
299
  end
285
300
 
301
+ def traverse_resources(resources: @export.api.resources, &block)
302
+ resources.each_value do |resource|
303
+ yield(resource)
304
+ traverse_resources(resources: resource.resources, &block) if resource.resources.any?
305
+ end
306
+ end
307
+
308
+ def build_enum_types(enums)
309
+ enums.map do |name, enum|
310
+ { code: build_enum_type(name, enum), name: pascal_case(name) }
311
+ end
312
+ end
313
+
314
+ def build_type_definitions(types)
315
+ TypeAnalysis.topological_sort_types(types.transform_values(&:to_h)).map(&:first).map do |type_name|
316
+ type = types[type_name]
317
+ code = type.union? ? build_union_type(type_name, type) : build_interface(type_name, type)
318
+ { code:, name: pascal_case(type_name) }
319
+ end
320
+ end
321
+
322
+ private
323
+
324
+ def resolve_error_statuses(raises)
325
+ raises.map { |code| @export.api.error_codes[code].status }.uniq.sort
326
+ end
327
+
286
328
  def build_request_types(resource_name, action_name, request, parent_identifiers:)
287
329
  types = []
288
330
  return types unless request && (request.query? || request.body?)
@@ -306,32 +348,6 @@ module Apiwork
306
348
  types
307
349
  end
308
350
 
309
- def build_response_types(resource_name, action_name, response, parent_identifiers:)
310
- types = []
311
-
312
- if response.no_content?
313
- type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
314
- types << { code: "export type #{type_name} = never;", name: type_name }
315
- elsif response.body?
316
- type_name = action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)
317
- code = build_action_response_body_type(resource_name, action_name, response.body, parent_identifiers:)
318
- types << { code:, name: type_name }
319
-
320
- type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
321
- code = build_action_response_type(resource_name, action_name, { body: response.body }, parent_identifiers:)
322
- types << { code:, name: type_name }
323
- end
324
-
325
- types
326
- end
327
-
328
- def traverse_resources(resources: @export.api.resources, &block)
329
- resources.each_value do |resource|
330
- yield(resource)
331
- traverse_resources(resources: resource.resources, &block) if resource.resources.any?
332
- end
333
- end
334
-
335
351
  def type_or_enum_reference?(symbol)
336
352
  @export.api.types.key?(symbol) || @export.api.enums.key?(symbol)
337
353
  end
@@ -28,18 +28,20 @@ module Apiwork
28
28
  end
29
29
 
30
30
  def map(surface)
31
- @surface = surface
32
31
  parts = []
33
32
 
34
- enum_schemas = build_enum_schemas
33
+ enum_schemas = build_enum_schemas(surface.enums)
35
34
  parts << enum_schemas if enum_schemas.present?
36
35
 
37
- type_schemas = build_type_schemas
36
+ type_schemas = build_type_schemas(surface.types)
38
37
  parts << type_schemas if type_schemas.present?
39
38
 
40
39
  action_schemas = build_action_schemas
41
40
  parts << action_schemas if action_schemas.present?
42
41
 
42
+ action_response_schemas = build_action_response_schemas
43
+ parts << action_response_schemas if action_response_schemas.present?
44
+
43
45
  parts.join("\n\n")
44
46
  end
45
47
 
@@ -153,18 +155,27 @@ module Apiwork
153
155
  "export const #{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)}Schema = #{map_param(response_body)};"
154
156
  end
155
157
 
156
- def build_action_response_schema(resource_name, action_name, response, parent_identifiers: [])
157
- "export const #{action_type_name(
158
- resource_name,
159
- action_name,
160
- 'Response',
161
- parent_identifiers:,
162
- )}Schema = z.object({\n body: #{action_type_name(
163
- resource_name,
164
- action_name,
165
- 'ResponseBody',
166
- parent_identifiers:,
167
- )}Schema\n});"
158
+ def build_action_response_schema(resource_name, action_name, response, parent_identifiers: [], raises:)
159
+ schema_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
160
+
161
+ success_variant = if response.no_content?
162
+ 'z.object({ status: z.literal(204) })'
163
+ else
164
+ body_ref = "#{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)}Schema"
165
+ "z.object({ status: z.literal(200), body: #{body_ref} })"
166
+ end
167
+
168
+ error_statuses = resolve_error_statuses(raises)
169
+
170
+ if error_statuses.empty?
171
+ "export const #{schema_name}Schema = #{success_variant};"
172
+ else
173
+ error_variants = error_statuses.map do |status|
174
+ "z.object({ status: z.literal(#{status}), body: #{pascal_case(:error)}Schema })"
175
+ end
176
+ all_variants = ([success_variant] + error_variants).map { |variant| " #{variant}" }.join(",\n")
177
+ "export const #{schema_name}Schema = z.discriminatedUnion('status', [\n#{all_variants}\n]);"
178
+ end
168
179
  end
169
180
 
170
181
  def action_type_name(resource_name, action_name, suffix, parent_identifiers: [])
@@ -306,22 +317,20 @@ module Apiwork
306
317
  name.to_s.camelize(:upper)
307
318
  end
308
319
 
309
- private
310
-
311
- def build_enum_schemas
312
- return '' if @surface.enums.empty?
320
+ def build_enum_schemas(enums)
321
+ return '' if enums.empty?
313
322
 
314
- @surface.enums.map do |name, enum|
323
+ enums.map do |name, enum|
315
324
  "export const #{pascal_case(name)}Schema = z.enum([#{enum.values.sort.map { |value| "'#{value}'" }.join(', ')}]);"
316
325
  end.join("\n\n")
317
326
  end
318
327
 
319
- def build_type_schemas
320
- types_hash = @surface.types.transform_values(&:to_h)
328
+ def build_type_schemas(types)
329
+ types_hash = types.transform_values(&:to_h)
321
330
  lazy_types = TypeAnalysis.cycle_breaking_types(types_hash)
322
331
 
323
332
  TypeAnalysis.topological_sort_types(types_hash).map(&:first).map do |type_name|
324
- type = @surface.types[type_name]
333
+ type = types[type_name]
325
334
  recursive = lazy_types.include?(type_name)
326
335
 
327
336
  if type.union?
@@ -367,12 +376,22 @@ module Apiwork
367
376
  end
368
377
 
369
378
  response = action.response
370
- if response.no_content?
371
- schemas << "export const #{action_type_name(resource_name, action_name, 'Response', parent_identifiers:)}Schema = z.never();"
372
- elsif response.body?
373
- schemas << build_action_response_body_schema(resource_name, action_name, response.body, parent_identifiers:)
374
- schemas << build_action_response_schema(resource_name, action_name, { body: response.body }, parent_identifiers:)
375
- end
379
+ schemas << build_action_response_body_schema(resource_name, action_name, response.body, parent_identifiers:) if response.body?
380
+ end
381
+ end
382
+
383
+ schemas.join("\n\n")
384
+ end
385
+
386
+ def build_action_response_schemas
387
+ schemas = []
388
+
389
+ traverse_resources do |resource|
390
+ resource_name = resource.identifier.to_sym
391
+ parent_identifiers = resource.parent_identifiers
392
+
393
+ resource.actions.each do |action_name, action|
394
+ schemas << build_action_response_schema(resource_name, action_name, action.response, parent_identifiers:, raises: action.raises)
376
395
  end
377
396
  end
378
397
 
@@ -386,6 +405,8 @@ module Apiwork
386
405
  end
387
406
  end
388
407
 
408
+ private
409
+
389
410
  def type_or_enum_reference?(symbol)
390
411
  @export.api.types.key?(symbol) || @export.api.enums.key?(symbol)
391
412
  end
@@ -399,6 +420,10 @@ module Apiwork
399
420
  referenced_type.shape.key?(discriminator)
400
421
  end
401
422
 
423
+ def resolve_error_statuses(raises)
424
+ raises.map { |code| @export.api.error_codes[code].status }.uniq.sort
425
+ end
426
+
402
427
  def resolve_enum_schema(param)
403
428
  return nil unless param.scalar? && param.enum?
404
429
 
@@ -74,6 +74,7 @@ module Apiwork
74
74
  register(OpenAPI)
75
75
  register(TypeScript)
76
76
  register(Zod)
77
+ register(Sorbus)
77
78
  end
78
79
  end
79
80
  end
@@ -18,6 +18,14 @@ module Apiwork
18
18
  @dump = dump
19
19
  end
20
20
 
21
+ # @api public
22
+ # The description for this request.
23
+ #
24
+ # @return [String, nil]
25
+ def description
26
+ @dump[:description]
27
+ end
28
+
21
29
  # @api public
22
30
  # The query for this request.
23
31
  #
@@ -56,6 +64,7 @@ module Apiwork
56
64
  # @return [Hash]
57
65
  def to_h
58
66
  {
67
+ description:,
59
68
  body: body.transform_values(&:to_h),
60
69
  query: query.transform_values(&:to_h),
61
70
  }
@@ -28,6 +28,14 @@ module Apiwork
28
28
  @body ||= @dump[:body] ? Param.build(@dump[:body]) : nil
29
29
  end
30
30
 
31
+ # @api public
32
+ # The description for this response.
33
+ #
34
+ # @return [String, nil]
35
+ def description
36
+ @dump[:description]
37
+ end
38
+
31
39
  # @api public
32
40
  # Whether this response has no content.
33
41
  #
@@ -49,7 +57,7 @@ module Apiwork
49
57
  #
50
58
  # @return [Hash]
51
59
  def to_h
52
- { body: body&.to_h, no_content: no_content? }
60
+ { description:, body: body&.to_h, no_content: no_content? }
53
61
  end
54
62
  end
55
63
  end
@@ -23,7 +23,7 @@ module Apiwork
23
23
 
24
24
  private
25
25
 
26
- def i18n_lookup(field)
26
+ def i18n_lookup(*segments)
27
27
  contract_class = @contract_action.contract_class
28
28
  return nil unless contract_class.name
29
29
 
@@ -32,15 +32,16 @@ module Apiwork
32
32
  contract_class.name.demodulize.delete_suffix('Contract').underscore,
33
33
  :actions,
34
34
  @contract_action.name,
35
- field,
35
+ *segments,
36
36
  )
37
37
  end
38
38
 
39
39
  def build_request(request)
40
- return { body: {}, query: {} } unless request
40
+ return { body: {}, description: i18n_lookup(:request, :description), query: {} } unless request
41
41
 
42
42
  {
43
43
  body: build_param(request.body),
44
+ description: request.description || i18n_lookup(:request, :description),
44
45
  query: build_param(request.query),
45
46
  }
46
47
  end
@@ -50,13 +51,14 @@ module Apiwork
50
51
  end
51
52
 
52
53
  def build_response(response)
53
- return { body: {}, no_content: false } unless response
54
- return { body: {}, no_content: true } if response.no_content?
54
+ return { body: {}, description: i18n_lookup(:response, :description), no_content: false } unless response
55
+ return { body: {}, description: response.description || i18n_lookup(:response, :description), no_content: true } if response.no_content?
55
56
 
57
+ description = response.description || i18n_lookup(:response, :description)
56
58
  body_shape = response.body
57
- return { body: {}, no_content: false } unless body_shape
59
+ return { description:, body: {}, no_content: false } unless body_shape
58
60
 
59
- { body: Param.new(body_shape, result_wrapper: response.result_wrapper).to_h, no_content: false }
61
+ { description:, body: Param.new(body_shape).to_h, no_content: false }
60
62
  end
61
63
 
62
64
  def raises
@@ -4,9 +4,8 @@ module Apiwork
4
4
  module Introspection
5
5
  module Dump
6
6
  class Param
7
- def initialize(contract_param, result_wrapper: nil, visited: Set.new)
7
+ def initialize(contract_param, visited: Set.new)
8
8
  @contract_param = contract_param
9
- @result_wrapper = result_wrapper
10
9
  @visited = visited
11
10
  @import_prefix_cache = {}
12
11
  end
@@ -14,8 +13,6 @@ module Apiwork
14
13
  def to_h
15
14
  return nil unless @contract_param
16
15
 
17
- return build_result_wrapped if @result_wrapper
18
-
19
16
  result = {}
20
17
 
21
18
  @contract_param.params.sort_by { |name, _options| name.to_s }.each do |name, param_options|
@@ -31,38 +28,6 @@ module Apiwork
31
28
 
32
29
  private
33
30
 
34
- def build_result_wrapped
35
- success_type = @result_wrapper[:success_type]
36
- error_type = @result_wrapper[:error_type]
37
-
38
- success_variant = if success_type
39
- { reference: success_type, type: :reference }
40
- else
41
- { reference: nil, shape: build_success_params, type: :object }
42
- end
43
-
44
- error_variant = if error_type
45
- { reference: error_type, type: :reference }
46
- else
47
- { reference: nil, shape: {}, type: :object }
48
- end
49
-
50
- {
51
- type: :union,
52
- variants: [success_variant, error_variant],
53
- }
54
- end
55
-
56
- def build_success_params
57
- success_params = {}
58
- @contract_param.params.sort_by { |name, _options| name.to_s }.each do |name, param_options|
59
- dumped = build_param(name, param_options)
60
- dumped[:optional] = true if param_options[:optional]
61
- success_params[name] = dumped
62
- end
63
- success_params
64
- end
65
-
66
31
  def build_param(name, options)
67
32
  return build_union_param(options) if options[:type] == :union
68
33
  return build_custom_type_param(options) if options[:custom_type]
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apiwork
4
- VERSION = '0.1.2'
4
+ VERSION = '0.3.0'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: apiwork
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - skiftle
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-02-24 00:00:00.000000000 Z
11
+ date: 2026-02-28 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails
@@ -122,7 +122,7 @@ dependencies:
122
122
  - - "~>"
123
123
  - !ruby/object:Gem::Version
124
124
  version: '0.9'
125
- description:
125
+ description: Define your API once — and generate everything from it
126
126
  email:
127
127
  executables: []
128
128
  extensions: []
@@ -252,6 +252,8 @@ files:
252
252
  - lib/apiwork/export/pipeline.rb
253
253
  - lib/apiwork/export/pipeline/writer.rb
254
254
  - lib/apiwork/export/registry.rb
255
+ - lib/apiwork/export/sorbus.rb
256
+ - lib/apiwork/export/sorbus_mapper.rb
255
257
  - lib/apiwork/export/surface_resolver.rb
256
258
  - lib/apiwork/export/type_analysis.rb
257
259
  - lib/apiwork/export/type_script.rb
@@ -330,7 +332,9 @@ homepage: https://apiwork.dev
330
332
  licenses:
331
333
  - MIT
332
334
  metadata:
335
+ bug_tracker_uri: https://github.com/skiftle/apiwork/issues
333
336
  changelog_uri: https://github.com/skiftle/apiwork/blob/main/CHANGELOG.md
337
+ documentation_uri: https://apiwork.dev/guide/introduction
334
338
  homepage_uri: https://apiwork.dev
335
339
  rubygems_mfa_required: 'true'
336
340
  source_code_uri: https://github.com/skiftle/apiwork
@@ -352,5 +356,5 @@ requirements: []
352
356
  rubygems_version: 3.4.19
353
357
  signing_key:
354
358
  specification_version: 4
355
- summary: The craft of API design
359
+ summary: Typed APIs for Rails
356
360
  test_files: []