apiwork 0.1.1 → 0.2.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: dffa2da3925e765760fc1b380f2f5fdb48ed0d055a4d6bfe87055b81577183ef
4
- data.tar.gz: 85995c7465a4875da8ebc72ef04606b198667b8344c9f4717da3465fe5d06f19
3
+ metadata.gz: ef169b6db5b913f078945dbf9b29939139cd80d5d77e07c0339e9df070dfb259
4
+ data.tar.gz: b979dc9b7b721f5949952a22572fa8ea99593f59f1c5161a91fc8757b28205d5
5
5
  SHA512:
6
- metadata.gz: 7d71c49e6cb7d238d5adb40c0a7b829536bdfa4cee09cd4993542906cec0aa6f861115a1e4dafcb7f4523a91be06aeffa02e1643d7b71a483b7b0cb5a9b6775b
7
- data.tar.gz: e61222b9596f21de4a8af7cf1279db74a5db1c426fbd5f10affc083ce30d4c6f0da205105cec0355c2704830aad8574804dbb635f75582b5463b3391be455d9f
6
+ metadata.gz: 46548e8a016ade99fecc507ac9cb7d575b21278d4981e2ea5ebed9ddbdb81b2ff270669c4acca0f0cd6ce5e7437c9b14f62d51eff650492160e136f950a040fe
7
+ data.tar.gz: 8aabaa2bd3d750d425429ba4f47d034a07c1562c3ca5f90405d2c1d17e1f159d499e64bd906667bf9f6fe7dd3ee0640863dcb797a264ba985fcee6753f74925b
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.
@@ -270,15 +270,17 @@ module Apiwork
270
270
  else
271
271
  build_custom_action_response(contract_class, representation_class, action, contract_action)
272
272
  end
273
+
274
+ build_request_query_type(contract_class, action.name, contract_action)
275
+ build_request_body_type(contract_class, action.name, contract_action)
273
276
  end
274
277
 
275
278
  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)
279
+ build_response_body_type(contract_class, representation_class, action.name, :member)
277
280
  member_shape_class = self.class.member_wrapper.shape_class
278
281
  data_type = resolve_resource_data_type(representation_class)
279
282
 
280
283
  contract_action.response do |response|
281
- response.result_wrapper = result_wrapper
282
284
  response.body do |body|
283
285
  member_shape_class.apply(body, representation_class.root_key, capabilities, representation_class, :member, data_type:)
284
286
  end
@@ -286,12 +288,11 @@ module Apiwork
286
288
  end
287
289
 
288
290
  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)
291
+ build_response_body_type(contract_class, representation_class, action.name, :collection)
290
292
  collection_shape_class = self.class.collection_wrapper.shape_class
291
293
  data_type = resolve_resource_data_type(representation_class)
292
294
 
293
295
  contract_action.response do |response|
294
- response.result_wrapper = result_wrapper
295
296
  response.body do |body|
296
297
  collection_shape_class.apply(body, representation_class.root_key, capabilities, representation_class, :collection, data_type:)
297
298
  end
@@ -308,23 +309,51 @@ module Apiwork
308
309
  end
309
310
  end
310
311
 
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
312
+ def build_response_body_type(contract_class, representation_class, action_name, response_type)
313
+ type_name = [action_name, 'response_body'].join('_').to_sym
314
+ return if contract_class.type?(type_name)
313
315
 
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)
316
+ shape_class = if response_type == :collection
317
+ self.class.collection_wrapper.shape_class
318
+ else
319
+ self.class.member_wrapper.shape_class
320
+ end
321
+ data_type = resolve_resource_data_type(representation_class)
321
322
 
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
323
+ contract_class.object(type_name) do |object|
324
+ shape_class.apply(object, representation_class.root_key, capabilities, representation_class, response_type, data_type:)
325
+ end
326
+ end
327
+
328
+ def build_request_query_type(contract_class, action_name, contract_action)
329
+ request = contract_action.request
330
+ return unless request.query.params.any?
331
+
332
+ type_name = [action_name, 'request_query'].join('_').to_sym
333
+ return if contract_class.type?(type_name)
334
+
335
+ contract_class.object(type_name) do |object|
336
+ request.query.params.each { |name, param| object.param(name, **normalize_request_param(param)) }
325
337
  end
338
+ end
339
+
340
+ def build_request_body_type(contract_class, action_name, contract_action)
341
+ request = contract_action.request
342
+ return unless request.body.params.any?
343
+
344
+ type_name = [action_name, 'request_body'].join('_').to_sym
345
+ return if contract_class.type?(type_name)
346
+
347
+ contract_class.object(type_name) do |object|
348
+ request.body.params.each { |name, param| object.param(name, **normalize_request_param(param)) }
349
+ end
350
+ end
326
351
 
327
- { error_type: :error_response_body, success_type: contract_class.scoped_type_name(success_type_name) }
352
+ def normalize_request_param(param)
353
+ options = param.except(:name, :custom_type, :union, :partial)
354
+ options[:type] = param[:custom_type] if param[:custom_type]
355
+ options[:shape] = param[:union] if param[:union]
356
+ options
328
357
  end
329
358
 
330
359
  def resolve_resource_data_type(representation_class)
@@ -11,13 +11,10 @@ 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
21
18
  @no_content = false
22
19
  end
23
20
 
@@ -203,39 +203,18 @@ module Apiwork
203
203
  if response.no_content?
204
204
  responses[:'204'] = { description: 'No content' }
205
205
  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
- },
206
+ responses[:'200'] = {
207
+ content: {
208
+ 'application/json': {
209
+ schema: map_param(response.body),
231
210
  },
232
- description: 'Successful response',
233
- }
211
+ },
212
+ description: 'Successful response',
213
+ }
234
214
 
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
215
+ raises.each do |code|
216
+ error_code = api.error_codes[code]
217
+ responses[error_code.status.to_s.to_sym] = build_error_response(error_code.description)
239
218
  end
240
219
  elsif response
241
220
  responses[:'200'] = {
@@ -280,17 +259,6 @@ module Apiwork
280
259
  }
281
260
  end
282
261
 
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
262
  def surface
295
263
  @surface ||= SurfaceResolver.resolve(api)
296
264
  end
@@ -116,18 +116,25 @@ module Apiwork
116
116
  "export type #{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)} = #{map_param(response_body_definition)};"
117
117
  end
118
118
 
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}"
119
+ def build_action_response_type(resource_name, action_name, response, parent_identifiers: [], raises:)
120
+ type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
121
+
122
+ success_variant = if response.no_content?
123
+ '{ status: 204 }'
124
+ else
125
+ body_ref = action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)
126
+ "{ status: 200; body: #{body_ref} }"
127
+ end
128
+
129
+ error_statuses = resolve_error_statuses(raises)
130
+
131
+ if error_statuses.empty?
132
+ "export type #{type_name} = #{success_variant};"
133
+ else
134
+ error_variants = error_statuses.map { |status| "{ status: #{status}; body: #{pascal_case(:error_response_body)} }" }
135
+ all_variants = ([success_variant] + error_variants).map { |variant| " | #{variant}" }.join("\n")
136
+ "export type #{type_name} =\n#{all_variants};"
137
+ end
131
138
  end
132
139
 
133
140
  def action_type_name(resource_name, action_name, suffix, parent_identifiers: [])
@@ -253,6 +260,10 @@ module Apiwork
253
260
 
254
261
  private
255
262
 
263
+ def resolve_error_statuses(raises)
264
+ raises.map { |code| @export.api.error_codes[code].status }.uniq.sort
265
+ end
266
+
256
267
  def build_enum_types(surface)
257
268
  surface.enums.map do |name, enum|
258
269
  { code: build_enum_type(name, enum), name: pascal_case(name) }
@@ -276,7 +287,7 @@ module Apiwork
276
287
 
277
288
  resource.actions.each do |action_name, action|
278
289
  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:))
290
+ types.concat(build_response_types(resource_name, action_name, action, parent_identifiers:))
280
291
  end
281
292
  end
282
293
 
@@ -306,22 +317,20 @@ module Apiwork
306
317
  types
307
318
  end
308
319
 
309
- def build_response_types(resource_name, action_name, response, parent_identifiers:)
320
+ def build_response_types(resource_name, action_name, action, parent_identifiers:)
310
321
  types = []
322
+ response = action.response
311
323
 
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?
324
+ if response.body?
316
325
  type_name = action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)
317
326
  code = build_action_response_body_type(resource_name, action_name, response.body, parent_identifiers:)
318
327
  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
328
  end
324
329
 
330
+ type_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
331
+ code = build_action_response_type(resource_name, action_name, response, parent_identifiers:, raises: action.raises)
332
+ types << { code:, name: type_name }
333
+
325
334
  types
326
335
  end
327
336
 
@@ -153,18 +153,27 @@ module Apiwork
153
153
  "export const #{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)}Schema = #{map_param(response_body)};"
154
154
  end
155
155
 
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});"
156
+ def build_action_response_schema(resource_name, action_name, response, parent_identifiers: [], raises:)
157
+ schema_name = action_type_name(resource_name, action_name, 'Response', parent_identifiers:)
158
+
159
+ success_variant = if response.no_content?
160
+ 'z.object({ status: z.literal(204) })'
161
+ else
162
+ body_ref = "#{action_type_name(resource_name, action_name, 'ResponseBody', parent_identifiers:)}Schema"
163
+ "z.object({ status: z.literal(200), body: #{body_ref} })"
164
+ end
165
+
166
+ error_statuses = resolve_error_statuses(raises)
167
+
168
+ if error_statuses.empty?
169
+ "export const #{schema_name}Schema = #{success_variant};"
170
+ else
171
+ error_variants = error_statuses.map do |status|
172
+ "z.object({ status: z.literal(#{status}), body: #{pascal_case(:error_response_body)}Schema })"
173
+ end
174
+ all_variants = ([success_variant] + error_variants).map { |variant| " #{variant}" }.join(",\n")
175
+ "export const #{schema_name}Schema = z.discriminatedUnion('status', [\n#{all_variants}\n]);"
176
+ end
168
177
  end
169
178
 
170
179
  def action_type_name(resource_name, action_name, suffix, parent_identifiers: [])
@@ -367,12 +376,9 @@ 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
+
381
+ schemas << build_action_response_schema(resource_name, action_name, response, parent_identifiers:, raises: action.raises)
376
382
  end
377
383
  end
378
384
 
@@ -399,6 +405,10 @@ module Apiwork
399
405
  referenced_type.shape.key?(discriminator)
400
406
  end
401
407
 
408
+ def resolve_error_statuses(raises)
409
+ raises.map { |code| @export.api.error_codes[code].status }.uniq.sort
410
+ end
411
+
402
412
  def resolve_enum_schema(param)
403
413
  return nil unless param.scalar? && param.enum?
404
414
 
@@ -56,7 +56,7 @@ module Apiwork
56
56
  body_shape = response.body
57
57
  return { body: {}, no_content: false } unless body_shape
58
58
 
59
- { body: Param.new(body_shape, result_wrapper: response.result_wrapper).to_h, no_content: false }
59
+ { body: Param.new(body_shape).to_h, no_content: false }
60
60
  end
61
61
 
62
62
  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,6 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'yard'
4
3
  require 'fileutils'
5
4
  require 'active_support/core_ext/string/inflections'
6
5
  require 'active_support/core_ext/object/blank'
@@ -22,6 +21,8 @@ module Apiwork
22
21
  end
23
22
 
24
23
  def generate
24
+ require 'yard'
25
+
25
26
  parse_source
26
27
  modules = extract_modules
27
28
  write_files(modules)
@@ -237,6 +238,7 @@ module Apiwork
237
238
  @modules = modules
238
239
  @modules_with_children = build_modules_with_children(modules)
239
240
 
241
+ write_root_index
240
242
  modules.each.with_index(1) do |mod, order|
241
243
  filepath = module_filepath(mod[:path])
242
244
  FileUtils.mkdir_p(File.dirname(filepath))
@@ -247,6 +249,22 @@ module Apiwork
247
249
  write_namespace_indexes
248
250
  end
249
251
 
252
+ def write_root_index
253
+ children = find_direct_children('Apiwork')
254
+ parts = []
255
+ parts << "---\norder: 2\n---\n"
256
+ parts << "# Reference\n"
257
+ parts << "Complete API reference for Apiwork's public classes.\n"
258
+
259
+ if children.any?
260
+ parts << "## Modules\n"
261
+ render_child_links(parts, 'Apiwork', children)
262
+ end
263
+
264
+ FileUtils.mkdir_p(OUTPUT_DIR)
265
+ File.write(File.join(OUTPUT_DIR, 'index.md'), parts.join("\n"))
266
+ end
267
+
250
268
  def write_namespace_indexes
251
269
  collect_all_folder_paths.each do |folder_parts|
252
270
  folder_path = File.join(OUTPUT_DIR, *folder_parts.map { |p| dasherize(p) })
@@ -337,8 +355,6 @@ module Apiwork
337
355
 
338
356
  def cleanup_old_files
339
357
  Dir.glob(File.join(OUTPUT_DIR, '**/*')).each do |entry|
340
- next if entry == File.join(OUTPUT_DIR, 'index.md')
341
-
342
358
  FileUtils.rm_rf(entry)
343
359
  end
344
360
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Apiwork
4
- VERSION = '0.1.1'
4
+ VERSION = '0.2.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.1
4
+ version: 0.2.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-26 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: []
@@ -330,7 +330,9 @@ homepage: https://apiwork.dev
330
330
  licenses:
331
331
  - MIT
332
332
  metadata:
333
+ bug_tracker_uri: https://github.com/skiftle/apiwork/issues
333
334
  changelog_uri: https://github.com/skiftle/apiwork/blob/main/CHANGELOG.md
335
+ documentation_uri: https://apiwork.dev/guide/introduction
334
336
  homepage_uri: https://apiwork.dev
335
337
  rubygems_mfa_required: 'true'
336
338
  source_code_uri: https://github.com/skiftle/apiwork
@@ -352,5 +354,5 @@ requirements: []
352
354
  rubygems_version: 3.4.19
353
355
  signing_key:
354
356
  specification_version: 4
355
- summary: The craft of API design
357
+ summary: Typed APIs for Rails
356
358
  test_files: []