Skip to content

Commit b24d0ac

Browse files
committed
Checkpoint
1 parent 4f9982b commit b24d0ac

15 files changed

Lines changed: 120 additions & 35 deletions

docs/core/contracts/actions.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,44 @@ action :create do
141141
end
142142
```
143143

144+
### no_content!
145+
146+
For actions that return HTTP 204 No Content (no response body):
147+
148+
```ruby
149+
action :soft_delete do
150+
response { no_content! }
151+
end
152+
```
153+
154+
This is the **default for DELETE method actions** (including `destroy`).
155+
156+
**Generated output:**
157+
- OpenAPI: `204 No Content` (no `content` key)
158+
- TypeScript: `never`
159+
- Zod: `z.never()`
160+
161+
To return data instead:
162+
163+
```ruby
164+
action :destroy do
165+
response do
166+
body do
167+
param :deleted_at, type: :datetime
168+
end
169+
end
170+
end
171+
```
172+
173+
**Important:** `meta` cannot be used with `no_content!` since 204 has no body.
174+
If you need `meta`, use an empty response instead:
175+
176+
```ruby
177+
action :destroy do
178+
response {} # 200 OK with { meta?: object }
179+
end
180+
```
181+
144182
## Raises
145183

146184
Declare which errors an action can raise:

lib/apiwork/adapter/apiwork.rb

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,6 @@ def render_collection(collection, schema_class, action_data)
3131
end
3232

3333
def render_record(record, schema_class, action_data)
34-
return { meta: action_data.meta.presence || {} } if action_data.delete?
35-
3634
RecordValidator.validate(record, schema_class:)
3735
data = RecordLoader.load(record, schema_class, action_data.query)
3836
serialized = schema_class.serialize(data, context: action_data.context, include: action_data.query[:include])

lib/apiwork/adapter/apiwork/contract_builder.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -234,9 +234,11 @@ def build_response_for_action(action_definition, action_name, action_metadata)
234234
body { builder.send(:single_response, self) }
235235
end
236236
when :destroy
237-
action_definition.response {}
237+
action_definition.response { no_content! }
238238
else
239-
if action_metadata[:type] == :collection
239+
if action_metadata[:method] == :delete
240+
action_definition.response { no_content! }
241+
elsif action_metadata[:type] == :collection
240242
action_definition.response do
241243
body { builder.send(:collection_response, self) }
242244
end

lib/apiwork/contract/response_definition.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,15 @@ def initialize(contract_class:, action_name:)
1111
@contract_class = contract_class
1212
@action_name = action_name
1313
@body_definition = nil
14+
@no_content = false
15+
end
16+
17+
def no_content?
18+
@no_content
19+
end
20+
21+
def no_content!
22+
@no_content = true
1423
end
1524

1625
def body(&block)

lib/apiwork/controller/serialization.rb

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ module Serialization
66
extend ActiveSupport::Concern
77

88
def respond_with(data, meta: {}, status: nil)
9+
action_def = contract_class.action_definitions[action_name.to_sym]
10+
11+
if action_def&.response_definition&.no_content?
12+
head :no_content
13+
return
14+
end
15+
916
schema_class = contract_class.schema_class
1017

1118
json = if schema_class

lib/apiwork/introspection/action_serializer.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ def serialize_request(request_definition)
5959

6060
def serialize_response(response_definition)
6161
return nil unless response_definition
62+
return { no_content: true } if response_definition.no_content?
6263

6364
{ body: response_definition.body_definition&.then { DefinitionSerializer.new(_1).serialize } }.compact.presence
6465
end

lib/apiwork/spec/openapi.rb

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def build_operation(resource_name, resource_path, action_name, action_data, reso
8181
end
8282

8383
response_data = action_data[:response]
84-
operation[:responses] = build_responses(action_name, response_data&.dig(:body), action_data[:raises] || [])
84+
operation[:responses] = build_responses(action_name, response_data, action_data[:raises] || [])
8585

8686
operation.compact
8787
end
@@ -163,11 +163,15 @@ def build_request_body(request_params, action_name)
163163
}
164164
end
165165

166-
def build_responses(action_name, response_params, action_raises = [])
166+
def build_responses(action_name, response_data, action_raises = [])
167167
responses = {}
168168
combined_raises = (raises + action_raises).uniq
169169

170-
if response_params
170+
if response_data&.dig(:no_content)
171+
responses[:'204'] = { description: 'No content' }
172+
elsif response_data&.dig(:body)
173+
response_params = response_data[:body]
174+
171175
# Detect unwrapped union and separate success/error variants
172176
if response_params[:type] == :union && !response_params[:discriminator]
173177
success_variant = response_params[:variants][0]
@@ -201,10 +205,24 @@ def build_responses(action_name, response_params, action_raises = [])
201205
responses[error_data[:status].to_s.to_sym] = build_error_response(error_data[:description])
202206
end
203207
end
204-
else
205-
responses[:'204'] = {
206-
description: 'No content'
208+
elsif response_data
209+
# Empty response {} - 200 with optional meta
210+
responses[:'200'] = {
211+
description: 'Successful response',
212+
content: {
213+
'application/json': {
214+
schema: {
215+
type: 'object',
216+
properties: {
217+
meta: { type: 'object' }
218+
}
219+
}
220+
}
221+
}
207222
}
223+
else
224+
# No response definition at all - default to 204
225+
responses[:'204'] = { description: 'No content' }
208226
end
209227

210228
responses

lib/apiwork/spec/typescript.rb

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,19 @@ def build_all_typescript_types
6868
end
6969

7070
response_data = action_data[:response]
71-
next unless response_data && response_data[:body]
7271

73-
type_name = mapper.action_type_name(resource_name, action_name, 'ResponseBody', parent_path: parent_path)
74-
code = mapper.build_action_response_body_type(resource_name, action_name, response_data[:body], parent_path: parent_path)
75-
all_types << { name: type_name, code: code }
72+
if response_data&.dig(:no_content)
73+
type_name = mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
74+
all_types << { name: type_name, code: "export type #{type_name} = never;" }
75+
elsif response_data && response_data[:body]
76+
type_name = mapper.action_type_name(resource_name, action_name, 'ResponseBody', parent_path: parent_path)
77+
code = mapper.build_action_response_body_type(resource_name, action_name, response_data[:body], parent_path: parent_path)
78+
all_types << { name: type_name, code: code }
7679

77-
type_name = mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
78-
code = mapper.build_action_response_type(resource_name, action_name, response_data, parent_path: parent_path)
79-
all_types << { name: type_name, code: code }
80+
type_name = mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
81+
code = mapper.build_action_response_type(resource_name, action_name, response_data, parent_path: parent_path)
82+
all_types << { name: type_name, code: code }
83+
end
8084
end
8185
end
8286

lib/apiwork/spec/zod.rb

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,10 @@ def build_action_schemas
126126
end
127127

128128
response_data = action_data[:response]
129-
if response_data && response_data[:body]
129+
if response_data&.dig(:no_content)
130+
schema_name = zod_mapper.action_schema_name(resource_name, action_name, 'Response', parent_path: parent_path)
131+
schemas << "export const #{schema_name} = z.never();"
132+
elsif response_data && response_data[:body]
130133
schemas << zod_mapper.build_action_response_body_schema(resource_name, action_name, response_data[:body], parent_path: parent_path)
131134
schemas << zod_mapper.build_action_response_schema(resource_name, action_name, response_data, parent_path: parent_path)
132135
end
@@ -192,15 +195,19 @@ def build_typescript_types
192195
end
193196

194197
response_data = action_data[:response]
195-
next unless response_data && response_data[:body]
196198

197-
type_name = typescript_mapper.action_type_name(resource_name, action_name, 'ResponseBody', parent_path: parent_path)
198-
code = typescript_mapper.build_action_response_body_type(resource_name, action_name, response_data[:body], parent_path: parent_path)
199-
all_types << { name: type_name, code: code }
199+
if response_data&.dig(:no_content)
200+
type_name = typescript_mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
201+
all_types << { name: type_name, code: "export type #{type_name} = never;" }
202+
elsif response_data && response_data[:body]
203+
type_name = typescript_mapper.action_type_name(resource_name, action_name, 'ResponseBody', parent_path: parent_path)
204+
code = typescript_mapper.build_action_response_body_type(resource_name, action_name, response_data[:body], parent_path: parent_path)
205+
all_types << { name: type_name, code: code }
200206

201-
type_name = typescript_mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
202-
code = typescript_mapper.build_action_response_type(resource_name, action_name, response_data, parent_path: parent_path)
203-
all_types << { name: type_name, code: code }
207+
type_name = typescript_mapper.action_type_name(resource_name, action_name, 'Response', parent_path: parent_path)
208+
code = typescript_mapper.build_action_response_type(resource_name, action_name, response_data, parent_path: parent_path)
209+
all_types << { name: type_name, code: code }
210+
end
204211
end
205212
end
206213

spec/integration/association_edge_cases_spec.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
delete "/api/v1/posts/#{post.id}"
1515

16+
# PostContract explicitly defines a response body for destroy, so it returns 200 OK
1617
expect(response).to have_http_status(:ok)
1718
expect(Post.exists?(post.id)).to be(false)
1819
expect(Comment.exists?(comment1.id)).to be(false)
@@ -29,7 +30,7 @@
2930

3031
delete "/api/v1/posts/#{post.id}/comments/#{comment.id}"
3132

32-
expect(response).to have_http_status(:ok)
33+
expect(response).to have_http_status(:no_content)
3334
expect(Comment.exists?(comment.id)).to be(false)
3435
expect(Reply.exists?(reply1.id)).to be(false)
3536
expect(Reply.exists?(reply2.id)).to be(false)
@@ -42,6 +43,7 @@
4243

4344
delete "/api/v1/posts/#{post.id}"
4445

46+
# PostContract explicitly defines a response body for destroy, so it returns 200 OK
4547
expect(response).to have_http_status(:ok)
4648
expect(Post.exists?(post.id)).to be(false)
4749
expect(Comment.exists?(comment.id)).to be(false)

0 commit comments

Comments
 (0)