respondo-graphql 0.1.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 +7 -0
- data/CHANGELOG.md +26 -0
- data/README.md +143 -0
- data/lib/generators/respondo_graphql/install/install_generator.rb +224 -0
- data/lib/respondo-graphql.rb +5 -0
- data/lib/respondo_graphql/configuration.rb +64 -0
- data/lib/respondo_graphql/controller_helpers.rb +188 -0
- data/lib/respondo_graphql/railtie.rb +23 -0
- data/lib/respondo_graphql/response_builder.rb +167 -0
- data/lib/respondo_graphql/serializer.rb +110 -0
- data/lib/respondo_graphql/version.rb +5 -0
- data/lib/respondo_graphql.rb +56 -0
- data/respondo-graphql.gemspec +40 -0
- metadata +136 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 479397a32c82bd347b77d8ae9a342abb4b3796a3a6e33919661313a4e7a172dd
|
|
4
|
+
data.tar.gz: 84492ab253009751ffc4f7ffd391cc79576a1af64330eebb54179f31612356f3
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6a967a00d8e92435ebb7f69cf82a43aced84acfe9aafc2a53f2e55c7fb4a447d9d0607d1d19cd091417aba22c67d92b391c4b49c05276bc74a308364b9087d85
|
|
7
|
+
data.tar.gz: '01168e9e5bc5fdb2de915ff285a70c428bc12910ac6d63b03dd91b47ce24319b5e94c6c82f42f31b1cb3b8ae6247ade373e2a91b977f95eb1a05c813ef88a20b'
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.1.0] — Initial release
|
|
4
|
+
|
|
5
|
+
First release of `respondo-graphql`, the GraphQL sibling of the `respondo` JSON
|
|
6
|
+
gem. It is a standalone gem — no dependency on `respondo`.
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- GraphQL-spec response envelope: `errors` (failures only), `data`, `extensions`.
|
|
10
|
+
- 50 status-aware controller helpers with the same names and signatures as
|
|
11
|
+
`respondo`: ten 2xx helpers and forty 4xx/5xx helpers (`render_ok`,
|
|
12
|
+
`render_not_found`, `render_unprocessable`, …). 1xx and 3xx are omitted on
|
|
13
|
+
purpose — they have no meaning in a GraphQL response.
|
|
14
|
+
- Field-level errors become one `errors[]` entry per message, tagged
|
|
15
|
+
`extensions.field`; `errors[0]` is always a summary carrying the message.
|
|
16
|
+
- Configuration: `default_success_message`, `default_error_message`,
|
|
17
|
+
`include_request_id`, `camelize_keys`, `serializer`, `default_meta`,
|
|
18
|
+
`data_key`, `always_ok`, `auto_include`.
|
|
19
|
+
- `always_ok` sends every response with HTTP 200 (Apollo-style clients).
|
|
20
|
+
- `data_key` wraps Array / scalar payloads, since GraphQL `data` must be a map.
|
|
21
|
+
- Railtie that includes the helpers into `ActionController::Base` / `::API`.
|
|
22
|
+
It skips auto-include when the `respondo` JSON gem is loaded (both define the
|
|
23
|
+
same helper names); include `RespondoGraphql::ControllerHelpers` in the
|
|
24
|
+
GraphQL controller instead, where it takes precedence over the parent's helpers.
|
|
25
|
+
- `rails generate respondo_graphql:install` — interactive setup that writes
|
|
26
|
+
`config/initializers/respondo_graphql.rb`.
|
data/README.md
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# RespondoGraphql
|
|
4
|
+
|
|
5
|
+
**Consistent GraphQL-spec responses for Rails controllers — in one line.**
|
|
6
|
+
|
|
7
|
+
</div>
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
`respondo-graphql` is the GraphQL sibling of [`respondo`](https://github.com/spatelpatidar/respondo) (JSON). It is a **standalone gem**: it has no dependency on `respondo`, its own namespace (`RespondoGraphql`), its own config, and its own generator. Install either one, or both.
|
|
12
|
+
|
|
13
|
+
It gives your controllers one expressive helper per situation (`render_ok`, `render_not_found`, `render_unprocessable`, …) and always answers in the GraphQL response shape: `data`, `errors`, `extensions`.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
gem "respondo-graphql"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
bundle install
|
|
24
|
+
rails generate respondo_graphql:install
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
The Railtie includes the helpers into `ActionController::Base` and `ActionController::API` for you. (If the `respondo` JSON gem is installed too, see [Using both gems](#using-both-gems).)
|
|
28
|
+
|
|
29
|
+
## Usage
|
|
30
|
+
|
|
31
|
+
```ruby
|
|
32
|
+
class GraphqlController < ApplicationController
|
|
33
|
+
def show
|
|
34
|
+
user = User.find_by(id: params[:id])
|
|
35
|
+
return render_not_found(message: "User not found") unless user
|
|
36
|
+
|
|
37
|
+
render_ok(data: { user: user }, message: "User fetched")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def create
|
|
41
|
+
user = User.new(user_params)
|
|
42
|
+
if user.save
|
|
43
|
+
render_created(data: { user: user }, message: "Account created")
|
|
44
|
+
else
|
|
45
|
+
render_unprocessable(message: "Validation failed", errors: user.errors)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
**Success** — `render_ok(data: { user: { id: 1 } }, message: "User fetched")`
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{
|
|
55
|
+
"data": { "user": { "id": 1 } },
|
|
56
|
+
"extensions": { "message": "User fetched", "timestamp": "2026-10-04T10:30:00Z", "code": 200, "status": "ok" }
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
**Failure** — `data` is `null`, and `errors[0]` is always a summary carrying your message. Field-level errors follow, one entry per message:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{
|
|
64
|
+
"errors": [
|
|
65
|
+
{ "message": "Validation failed",
|
|
66
|
+
"extensions": { "code": "UNPROCESSABLE_CONTENT", "http": { "status": 422 } } },
|
|
67
|
+
{ "message": "email is invalid",
|
|
68
|
+
"extensions": { "code": "UNPROCESSABLE_CONTENT", "http": { "status": 422 }, "field": "email" } }
|
|
69
|
+
],
|
|
70
|
+
"data": null,
|
|
71
|
+
"extensions": { "message": "Validation failed", "timestamp": "2026-10-04T10:30:00Z", "code": 422, "status": "unprocessable_content" }
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
| What you pass | Where it ends up |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `data:` | `data` — always a map or `null` |
|
|
78
|
+
| `message:` | `extensions.message`, and `errors[0].message` on failure |
|
|
79
|
+
| `meta:` / `pagination:` | `extensions` |
|
|
80
|
+
| `errors: { field: [...] }` | one `errors[]` entry per message, tagged `extensions.field` |
|
|
81
|
+
| success vs. failure | presence of the `errors` array |
|
|
82
|
+
|
|
83
|
+
GraphQL requires `data` to be a map, so an Array or scalar payload is wrapped as `{ "result": [...] }` (rename it with `config.data_key`).
|
|
84
|
+
|
|
85
|
+
## Helpers
|
|
86
|
+
|
|
87
|
+
Same names and signatures as the `respondo` JSON gem.
|
|
88
|
+
|
|
89
|
+
| Group | Helpers | Arguments |
|
|
90
|
+
|---|---|---|
|
|
91
|
+
| 2xx with payload | `render_ok` `render_created` `render_accepted` `render_non_authoritative` `render_partial_content` `render_multi_status` `render_already_reported` `render_im_used` | `data:` `message:` `meta:` `pagination:` |
|
|
92
|
+
| 2xx no payload | `render_no_content` `render_reset_content` | `message:` `meta:` `pagination:` |
|
|
93
|
+
| 4xx / 5xx | `render_bad_request` `render_unauthorized` `render_forbidden` `render_not_found` `render_conflict` `render_unprocessable` `render_too_many_requests` `render_server_error` `render_service_unavailable` … (40 in total) | `message:` `errors:` `meta:` |
|
|
94
|
+
|
|
95
|
+
1xx (informational) and 3xx (redirect) helpers are deliberately left out: they are transport-level and have no meaning in a GraphQL response. The full list is in `RespondoGraphql::ControllerHelpers::SUCCESS_HELPERS` and `ERROR_HELPERS`.
|
|
96
|
+
|
|
97
|
+
## Configuration
|
|
98
|
+
|
|
99
|
+
```ruby
|
|
100
|
+
# config/initializers/respondo_graphql.rb (written by the generator)
|
|
101
|
+
RespondoGraphql.configure do |config|
|
|
102
|
+
config.default_success_message = "Success"
|
|
103
|
+
config.default_error_message = "An error occurred"
|
|
104
|
+
config.include_request_id = false # adds request_id to extensions
|
|
105
|
+
config.camelize_keys = false # created_at -> createdAt
|
|
106
|
+
config.always_ok = false # see below
|
|
107
|
+
config.data_key = :result # wrapper for Array / scalar data
|
|
108
|
+
config.default_meta = { api_version: "v1" }
|
|
109
|
+
config.serializer = ->(obj) { MySerializer.new(obj).as_json }
|
|
110
|
+
config.auto_include = true # see "Using both gems"
|
|
111
|
+
end
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
**`always_ok`** — many GraphQL clients, Apollo among them, treat non-2xx responses as network failures. Set `config.always_ok = true` to send every response with HTTP 200 and let the `errors` array carry the failure. The default (`false`) keeps each helper's own status (404 for `render_not_found`, …).
|
|
115
|
+
|
|
116
|
+
## Using both gems
|
|
117
|
+
|
|
118
|
+
`respondo` and `respondo-graphql` define the same helper names (`render_ok`, …), so they must not both be auto-included into every controller — whichever loaded last would silently win. When the Railtie sees that `respondo` is loaded, it **skips auto-include**. Keep `respondo` for your REST controllers and include the GraphQL helpers where you want them:
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
class GraphqlController < ApplicationController
|
|
122
|
+
include RespondoGraphql::ControllerHelpers # wins over respondo's helpers in this controller
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
You can also turn auto-include off yourself with `config.auto_include = false`.
|
|
127
|
+
|
|
128
|
+
## When to use this vs. graphql-ruby
|
|
129
|
+
|
|
130
|
+
Your GraphQL schema already produces valid responses for queries and mutations. RespondoGraphql is for everything your controller answers *outside* the schema — authentication failures, rate limits, malformed requests, REST-style endpoints that speak GraphQL-shaped JSON — so those responses look the same as the rest.
|
|
131
|
+
|
|
132
|
+
## Development
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
git clone https://github.com/spatelpatidar/respondo-graphql
|
|
136
|
+
cd respondo-graphql
|
|
137
|
+
bundle install
|
|
138
|
+
bundle exec rspec
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
Released under the [MIT License](LICENSE.txt).
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module RespondoGraphql
|
|
7
|
+
module Generators
|
|
8
|
+
# rails generate respondo_graphql:install
|
|
9
|
+
#
|
|
10
|
+
# Asks a few questions and writes config/initializers/respondo_graphql.rb.
|
|
11
|
+
# All output goes through $stdout (never Thor's `say`) so it is not echoed twice.
|
|
12
|
+
class InstallGenerator < Rails::Generators::Base
|
|
13
|
+
desc "Interactive setup — creates config/initializers/respondo_graphql.rb with your preferences."
|
|
14
|
+
|
|
15
|
+
def run_interactive_setup
|
|
16
|
+
out banner
|
|
17
|
+
out blank
|
|
18
|
+
|
|
19
|
+
unless confirm(" Ready to configure RespondoGraphql? (y/n) ")
|
|
20
|
+
out blank
|
|
21
|
+
out line(yellow(" Skipped. Run this again any time:"))
|
|
22
|
+
out line(" rails generate respondo_graphql:install")
|
|
23
|
+
out blank
|
|
24
|
+
return
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@cfg = {}
|
|
28
|
+
|
|
29
|
+
step_api_version
|
|
30
|
+
step_request_id
|
|
31
|
+
step_camelize
|
|
32
|
+
step_always_ok
|
|
33
|
+
|
|
34
|
+
print_summary
|
|
35
|
+
write_initializer
|
|
36
|
+
print_done
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
# =========================================================================
|
|
42
|
+
# Steps
|
|
43
|
+
# =========================================================================
|
|
44
|
+
|
|
45
|
+
def step_api_version
|
|
46
|
+
out section("API version")
|
|
47
|
+
out line(" Added to the extensions block of every response (e.g. v1, v2).")
|
|
48
|
+
out blank
|
|
49
|
+
@cfg[:api_version] = prompt_default("v1")
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def step_request_id
|
|
53
|
+
out section("Request ID")
|
|
54
|
+
out line(" Include Rails request.request_id in every response's extensions —")
|
|
55
|
+
out line(" useful for log correlation and debugging.")
|
|
56
|
+
out blank
|
|
57
|
+
@cfg[:include_request_id] = confirm(" Include request_id in every response? (y/n) ")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def step_camelize
|
|
61
|
+
out section("Key Format")
|
|
62
|
+
out line(" CamelCase all response keys:")
|
|
63
|
+
out line(yellow(' { "data": { "createdAt": "...", "userId": 1 } }'))
|
|
64
|
+
out blank
|
|
65
|
+
@cfg[:camelize_keys] = confirm(" Camelize all response keys? (y/n) ")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def step_always_ok
|
|
69
|
+
out section("HTTP status")
|
|
70
|
+
out line(" Many GraphQL clients (e.g. Apollo) treat non-2xx responses as network")
|
|
71
|
+
out line(" errors. Answer y to always send HTTP 200 and report failures in `errors`.")
|
|
72
|
+
out line(" Answer n to keep each helper's status (404 for render_not_found, ...).")
|
|
73
|
+
out blank
|
|
74
|
+
@cfg[:always_ok] = confirm(" Always send HTTP 200? (y/n) ")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# =========================================================================
|
|
78
|
+
# Summary
|
|
79
|
+
# =========================================================================
|
|
80
|
+
|
|
81
|
+
def print_summary
|
|
82
|
+
out blank
|
|
83
|
+
out divider
|
|
84
|
+
out line(cyan(" Configuration Summary"))
|
|
85
|
+
out divider
|
|
86
|
+
out blank
|
|
87
|
+
summary_row "API version", api_version.empty? ? "(none)" : api_version
|
|
88
|
+
summary_row "Include request_id", @cfg[:include_request_id]
|
|
89
|
+
summary_row "Camelize keys", @cfg[:camelize_keys]
|
|
90
|
+
summary_row "Always HTTP 200", @cfg[:always_ok]
|
|
91
|
+
out blank
|
|
92
|
+
out divider
|
|
93
|
+
out blank
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def summary_row(label, value)
|
|
97
|
+
shown = value == true ? green(value.inspect) : yellow(value.is_a?(String) ? value : value.inspect)
|
|
98
|
+
out " #{"#{label}:".ljust(24)}#{shown}\n"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def api_version
|
|
102
|
+
@cfg[:api_version].to_s
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# =========================================================================
|
|
106
|
+
# Write file
|
|
107
|
+
# =========================================================================
|
|
108
|
+
|
|
109
|
+
def write_initializer
|
|
110
|
+
dir = File.join(destination_root, "config", "initializers")
|
|
111
|
+
path = File.join(dir, "respondo_graphql.rb")
|
|
112
|
+
FileUtils.mkdir_p(dir)
|
|
113
|
+
File.write(path, build_content)
|
|
114
|
+
out line(green(" ✅ Created config/initializers/respondo_graphql.rb"))
|
|
115
|
+
out blank
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_content
|
|
119
|
+
lines = []
|
|
120
|
+
lines << "# frozen_string_literal: true"
|
|
121
|
+
lines << ""
|
|
122
|
+
lines << "# RespondoGraphql initializer"
|
|
123
|
+
lines << "# Generated by: rails generate respondo_graphql:install"
|
|
124
|
+
lines << "# RespondoGraphql version: #{RespondoGraphql::VERSION}"
|
|
125
|
+
lines << ""
|
|
126
|
+
lines << "RespondoGraphql.configure do |config|"
|
|
127
|
+
lines << " # Fallback messages when a helper is called without message:"
|
|
128
|
+
lines << " config.default_success_message = \"Success\""
|
|
129
|
+
lines << " config.default_error_message = \"An error occurred\""
|
|
130
|
+
lines << ""
|
|
131
|
+
lines << " # Include Rails request.request_id in every response's extensions."
|
|
132
|
+
lines << " config.include_request_id = #{@cfg[:include_request_id] ? true : false}"
|
|
133
|
+
lines << ""
|
|
134
|
+
lines << " # CamelCase all response keys."
|
|
135
|
+
lines << " config.camelize_keys = #{@cfg[:camelize_keys] ? true : false}"
|
|
136
|
+
lines << ""
|
|
137
|
+
lines << " # Send every response with HTTP 200; failures are reported in `errors`."
|
|
138
|
+
lines << " config.always_ok = #{@cfg[:always_ok] ? true : false}"
|
|
139
|
+
lines << ""
|
|
140
|
+
lines << " # GraphQL `data` must be a map: Array / scalar payloads are wrapped under this key."
|
|
141
|
+
lines << " config.data_key = :result"
|
|
142
|
+
lines << ""
|
|
143
|
+
lines << " # Static fields merged into the extensions block of every response."
|
|
144
|
+
lines << (api_version.empty? ? " config.default_meta = {}" : " config.default_meta = { api_version: #{api_version.inspect} }")
|
|
145
|
+
lines << ""
|
|
146
|
+
lines << " # Custom serializer — receives the payload, returns a Hash/Array."
|
|
147
|
+
lines << " # config.serializer = ->(obj) { MySerializer.new(obj).as_json }"
|
|
148
|
+
lines << ""
|
|
149
|
+
lines << " # Helpers are included into your controllers automatically. This is skipped"
|
|
150
|
+
lines << " # when the respondo (JSON) gem is also installed — include"
|
|
151
|
+
lines << " # RespondoGraphql::ControllerHelpers in your GraphQL controller instead."
|
|
152
|
+
lines << " # config.auto_include = false"
|
|
153
|
+
lines << "end"
|
|
154
|
+
lines << ""
|
|
155
|
+
lines.join("\n")
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
# =========================================================================
|
|
159
|
+
# Done
|
|
160
|
+
# =========================================================================
|
|
161
|
+
|
|
162
|
+
def print_done
|
|
163
|
+
out divider
|
|
164
|
+
out blank
|
|
165
|
+
out line(cyan(" 🎉 RespondoGraphql is ready!"))
|
|
166
|
+
out blank
|
|
167
|
+
out line(" Next steps:")
|
|
168
|
+
out line(" 1. Review config/initializers/respondo_graphql.rb")
|
|
169
|
+
out line(" 2. Use render_ok / render_not_found / render_unprocessable ... in your controllers")
|
|
170
|
+
out line(" 3. Re-run rails generate respondo_graphql:install to regenerate")
|
|
171
|
+
out blank
|
|
172
|
+
out divider
|
|
173
|
+
out blank
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
# =========================================================================
|
|
177
|
+
# Output primitives — all output goes through $stdout, never through Thor
|
|
178
|
+
# =========================================================================
|
|
179
|
+
|
|
180
|
+
def out(str)
|
|
181
|
+
$stdout.print str
|
|
182
|
+
$stdout.flush
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
def line(str)
|
|
186
|
+
"#{str}\n"
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def blank
|
|
190
|
+
"\n"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def divider
|
|
194
|
+
" #{cyan("─" * 68)}\n"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def section(title)
|
|
198
|
+
dashes = "─" * [0, 54 - title.length].max
|
|
199
|
+
"\n #{cyan("┌─ #{title} #{dashes}┐")}\n\n"
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def banner
|
|
203
|
+
green("\n RespondoGraphql — GraphQL-spec responses for Rails (v#{RespondoGraphql::VERSION})\n")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def prompt_default(default)
|
|
207
|
+
$stdout.print " #{cyan("›")} #{yellow("[#{default}]")}: "
|
|
208
|
+
$stdout.flush
|
|
209
|
+
result = $stdin.gets.to_s.strip
|
|
210
|
+
result.empty? ? default.to_s : result
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def confirm(question)
|
|
214
|
+
$stdout.print question
|
|
215
|
+
$stdout.flush
|
|
216
|
+
$stdin.gets.to_s.strip.downcase.start_with?("y")
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def cyan(text) = "\e[36m#{text}\e[0m"
|
|
220
|
+
def green(text) = "\e[32m#{text}\e[0m"
|
|
221
|
+
def yellow(text) = "\e[33m#{text}\e[0m"
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RespondoGraphql
|
|
4
|
+
# Global configuration for RespondoGraphql.
|
|
5
|
+
#
|
|
6
|
+
# @example
|
|
7
|
+
# RespondoGraphql.configure do |config|
|
|
8
|
+
# config.default_success_message = "OK"
|
|
9
|
+
# config.include_request_id = true
|
|
10
|
+
# config.camelize_keys = true
|
|
11
|
+
# config.always_ok = true
|
|
12
|
+
# end
|
|
13
|
+
class Configuration
|
|
14
|
+
# Message used when none is supplied to a success helper
|
|
15
|
+
attr_accessor :default_success_message
|
|
16
|
+
|
|
17
|
+
# Message used when none is supplied to an error helper
|
|
18
|
+
attr_accessor :default_error_message
|
|
19
|
+
|
|
20
|
+
# When true, includes request.request_id in every extensions block (Rails only)
|
|
21
|
+
attr_accessor :include_request_id
|
|
22
|
+
|
|
23
|
+
# When true, all response keys are camelized (e.g. created_at -> createdAt)
|
|
24
|
+
attr_accessor :camelize_keys
|
|
25
|
+
|
|
26
|
+
# Custom serializer callable — receives (object) and returns a Hash/Array.
|
|
27
|
+
# Defaults to nil (built-in serialization strategy is used).
|
|
28
|
+
# @example config.serializer = ->(obj) { MySerializer.new(obj).as_json }
|
|
29
|
+
attr_accessor :serializer
|
|
30
|
+
|
|
31
|
+
# Static key-value pairs merged into every response's `extensions` block.
|
|
32
|
+
# @example config.default_meta = { api_version: "v1" }
|
|
33
|
+
attr_accessor :default_meta
|
|
34
|
+
|
|
35
|
+
# GraphQL requires `data` to be a map, so a payload that is an Array or a
|
|
36
|
+
# scalar is wrapped as { <data_key> => payload }. Hash payloads pass through.
|
|
37
|
+
attr_accessor :data_key
|
|
38
|
+
|
|
39
|
+
# When true every response is sent with HTTP 200, whatever the helper's
|
|
40
|
+
# status, and failures are reported through the `errors` array. Many GraphQL
|
|
41
|
+
# clients (e.g. Apollo) treat non-2xx responses as network failures.
|
|
42
|
+
# Defaults to false so helper status codes keep meaning what they say.
|
|
43
|
+
attr_accessor :always_ok
|
|
44
|
+
|
|
45
|
+
# When true (default) the Railtie includes the helpers into
|
|
46
|
+
# ActionController::Base / ::API, so no `include` is needed. It is skipped
|
|
47
|
+
# automatically when the respondo (JSON) gem is also loaded, because both
|
|
48
|
+
# gems define the same helper names — include RespondoGraphql::ControllerHelpers
|
|
49
|
+
# in your GraphQL controller instead.
|
|
50
|
+
attr_accessor :auto_include
|
|
51
|
+
|
|
52
|
+
def initialize
|
|
53
|
+
@default_success_message = "Success"
|
|
54
|
+
@default_error_message = "An error occurred"
|
|
55
|
+
@include_request_id = false
|
|
56
|
+
@camelize_keys = false
|
|
57
|
+
@serializer = nil
|
|
58
|
+
@default_meta = {}
|
|
59
|
+
@data_key = :result
|
|
60
|
+
@always_ok = false
|
|
61
|
+
@auto_include = true
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RespondoGraphql
|
|
4
|
+
# Mixed into Rails controllers to render GraphQL-spec responses with
|
|
5
|
+
# expressive, status-aware helpers.
|
|
6
|
+
#
|
|
7
|
+
# Success helpers (2xx) — accept data:, message:, meta:, pagination:
|
|
8
|
+
# render_ok, render_created, render_accepted, render_non_authoritative,
|
|
9
|
+
# render_partial_content, render_multi_status, render_already_reported,
|
|
10
|
+
# render_im_used
|
|
11
|
+
#
|
|
12
|
+
# Success helpers (2xx) without a payload — accept message:, meta:, pagination:
|
|
13
|
+
# render_no_content, render_reset_content
|
|
14
|
+
#
|
|
15
|
+
# Error helpers (4xx / 5xx) — accept message:, errors:, meta:
|
|
16
|
+
# render_bad_request, render_unauthorized, render_payment_required,
|
|
17
|
+
# render_forbidden, render_not_found, render_method_not_allowed,
|
|
18
|
+
# render_not_acceptable, render_proxy_auth_required, render_request_timeout,
|
|
19
|
+
# render_conflict, render_gone, render_length_required,
|
|
20
|
+
# render_precondition_failed, render_payload_too_large, render_uri_too_long,
|
|
21
|
+
# render_unsupported_media_type, render_range_not_satisfiable,
|
|
22
|
+
# render_expectation_failed, render_im_a_teapot, render_misdirected_request,
|
|
23
|
+
# render_unprocessable, render_locked, render_failed_dependency,
|
|
24
|
+
# render_too_early, render_upgrade_required, render_precondition_required,
|
|
25
|
+
# render_too_many_requests, render_request_header_fields_too_large,
|
|
26
|
+
# render_unavailable_for_legal_reasons, render_server_error,
|
|
27
|
+
# render_not_implemented, render_bad_gateway, render_service_unavailable,
|
|
28
|
+
# render_gateway_timeout, render_http_version_not_supported,
|
|
29
|
+
# render_variant_also_negotiates, render_insufficient_storage,
|
|
30
|
+
# render_loop_detected, render_not_extended,
|
|
31
|
+
# render_network_authentication_required
|
|
32
|
+
#
|
|
33
|
+
# 1xx (informational) and 3xx (redirect) helpers are intentionally not
|
|
34
|
+
# provided: they are transport-level and have no meaning in a GraphQL response.
|
|
35
|
+
module ControllerHelpers
|
|
36
|
+
# name => [numeric code, Rails status symbol, default message, takes data?]
|
|
37
|
+
SUCCESS_HELPERS = {
|
|
38
|
+
ok: { code: 200, status: :ok, message: nil, data: true },
|
|
39
|
+
created: { code: 201, status: :created, message: "Created successfully", data: true },
|
|
40
|
+
accepted: { code: 202, status: :accepted, message: "Request accepted and is being processed", data: true },
|
|
41
|
+
non_authoritative: { code: 203, status: :non_authoritative_information, message: "Non-authoritative information", data: true },
|
|
42
|
+
no_content: { code: 204, status: :no_content, message: "Deleted successfully", data: false },
|
|
43
|
+
reset_content: { code: 205, status: :reset_content, message: "Reset content", data: false },
|
|
44
|
+
partial_content: { code: 206, status: :partial_content, message: "Partial content returned", data: true },
|
|
45
|
+
multi_status: { code: 207, status: :multi_status, message: "Multi-status response", data: true },
|
|
46
|
+
already_reported: { code: 208, status: :already_reported, message: "Already reported", data: true },
|
|
47
|
+
im_used: { code: 226, status: :im_used, message: "IM used", data: true }
|
|
48
|
+
}.freeze
|
|
49
|
+
|
|
50
|
+
# name => [numeric code, Rails status symbol, default message]
|
|
51
|
+
ERROR_HELPERS = {
|
|
52
|
+
bad_request: { code: 400, status: :bad_request, message: "Bad request" },
|
|
53
|
+
unauthorized: { code: 401, status: :unauthorized, message: "Unauthorized" },
|
|
54
|
+
payment_required: { code: 402, status: :payment_required, message: "Payment required to access this resource" },
|
|
55
|
+
forbidden: { code: 403, status: :forbidden, message: "You do not have permission to perform this action" },
|
|
56
|
+
not_found: { code: 404, status: :not_found, message: "Resource not found" },
|
|
57
|
+
method_not_allowed: { code: 405, status: :method_not_allowed, message: "HTTP method not allowed" },
|
|
58
|
+
not_acceptable: { code: 406, status: :not_acceptable, message: "Requested format not acceptable" },
|
|
59
|
+
proxy_auth_required: { code: 407, status: :proxy_authentication_required, message: "Proxy authentication required" },
|
|
60
|
+
request_timeout: { code: 408, status: :request_timeout, message: "Request timed out" },
|
|
61
|
+
conflict: { code: 409, status: :conflict, message: "Resource conflict" },
|
|
62
|
+
gone: { code: 410, status: :gone, message: "Resource no longer available" },
|
|
63
|
+
length_required: { code: 411, status: :length_required, message: "Content-Length header required" },
|
|
64
|
+
precondition_failed: { code: 412, status: :precondition_failed, message: "Precondition failed" },
|
|
65
|
+
payload_too_large: { code: 413, status: :payload_too_large, message: "Payload too large" },
|
|
66
|
+
uri_too_long: { code: 414, status: :uri_too_long, message: "URI too long" },
|
|
67
|
+
unsupported_media_type: { code: 415, status: :unsupported_media_type, message: "Unsupported media type" },
|
|
68
|
+
range_not_satisfiable: { code: 416, status: :range_not_satisfiable, message: "Range not satisfiable" },
|
|
69
|
+
expectation_failed: { code: 417, status: :expectation_failed, message: "Expectation failed" },
|
|
70
|
+
im_a_teapot: { code: 418, status: :im_a_teapot, message: "I'm a teapot" },
|
|
71
|
+
misdirected_request: { code: 421, status: :misdirected_request, message: "Misdirected request" },
|
|
72
|
+
unprocessable: { code: 422, status: :unprocessable_content, message: "Validation failed" },
|
|
73
|
+
locked: { code: 423, status: :locked, message: "Resource is locked" },
|
|
74
|
+
failed_dependency: { code: 424, status: :failed_dependency, message: "Failed dependency" },
|
|
75
|
+
too_early: { code: 425, status: :too_early, message: "Too early" },
|
|
76
|
+
upgrade_required: { code: 426, status: :upgrade_required, message: "Upgrade required" },
|
|
77
|
+
precondition_required: { code: 428, status: :precondition_required, message: "Precondition required" },
|
|
78
|
+
too_many_requests: { code: 429, status: :too_many_requests, message: "Too many requests. Please slow down." },
|
|
79
|
+
request_header_fields_too_large: { code: 431, status: :request_header_fields_too_large, message: "Request header fields too large" },
|
|
80
|
+
unavailable_for_legal_reasons: { code: 451, status: :unavailable_for_legal_reasons, message: "Unavailable for legal reasons" },
|
|
81
|
+
server_error: { code: 500, status: :internal_server_error, message: "An unexpected error occurred" },
|
|
82
|
+
not_implemented: { code: 501, status: :not_implemented, message: "This feature is not yet implemented" },
|
|
83
|
+
bad_gateway: { code: 502, status: :bad_gateway, message: "Bad gateway — upstream service error" },
|
|
84
|
+
service_unavailable: { code: 503, status: :service_unavailable, message: "Service temporarily unavailable" },
|
|
85
|
+
gateway_timeout: { code: 504, status: :gateway_timeout, message: "Gateway timeout — upstream service did not respond" },
|
|
86
|
+
http_version_not_supported: { code: 505, status: :http_version_not_supported, message: "HTTP version not supported" },
|
|
87
|
+
variant_also_negotiates: { code: 506, status: :variant_also_negotiates, message: "Variant also negotiates" },
|
|
88
|
+
insufficient_storage: { code: 507, status: :insufficient_storage, message: "Insufficient storage" },
|
|
89
|
+
loop_detected: { code: 508, status: :loop_detected, message: "Loop detected" },
|
|
90
|
+
not_extended: { code: 510, status: :not_extended, message: "Not extended" },
|
|
91
|
+
network_authentication_required: { code: 511, status: :network_authentication_required, message: "Network authentication required" }
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
SUCCESS_HELPERS.each do |name, spec|
|
|
95
|
+
default_message = spec[:message]
|
|
96
|
+
|
|
97
|
+
if spec[:data]
|
|
98
|
+
# e.g. render_ok(data: @user, message: "User fetched", meta: {}, pagination: nil)
|
|
99
|
+
define_method(:"render_#{name}") do |data: nil, message: default_message, meta: {}, pagination: nil|
|
|
100
|
+
render_success(data: data, message: message, meta: meta, pagination: pagination,
|
|
101
|
+
code: spec[:code], status: spec[:status])
|
|
102
|
+
end
|
|
103
|
+
else
|
|
104
|
+
# e.g. render_no_content(message: "Deleted", meta: {}, pagination: nil)
|
|
105
|
+
define_method(:"render_#{name}") do |message: default_message, meta: {}, pagination: nil|
|
|
106
|
+
render_success(data: nil, message: message, meta: meta, pagination: pagination,
|
|
107
|
+
code: spec[:code], status: spec[:status])
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
ERROR_HELPERS.each do |name, spec|
|
|
113
|
+
default_message = spec[:message]
|
|
114
|
+
|
|
115
|
+
# e.g. render_not_found(message: "User not found", errors: nil, meta: {})
|
|
116
|
+
define_method(:"render_#{name}") do |message: default_message, errors: nil, meta: {}|
|
|
117
|
+
render_error(message: message, errors: errors, meta: meta, code: spec[:code], status: spec[:status])
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
# =========================================================================
|
|
124
|
+
# Core DSL — every helper delegates to these two
|
|
125
|
+
# =========================================================================
|
|
126
|
+
|
|
127
|
+
# Render a successful GraphQL response.
|
|
128
|
+
#
|
|
129
|
+
# @param data [Object] payload — AR model, collection, Hash, Array, nil
|
|
130
|
+
# @param message [String] human-readable description (goes in extensions)
|
|
131
|
+
# @param meta [Hash] extra fields merged into extensions
|
|
132
|
+
# @param pagination [Hash, nil] pagination hash built by the caller
|
|
133
|
+
# @param code [Integer, nil] numeric HTTP code recorded in extensions
|
|
134
|
+
# @param status [Symbol, Integer] HTTP status (default: :ok / 200)
|
|
135
|
+
def render_success(data: nil, message: nil, meta: {}, code: nil, pagination: nil, status: :ok)
|
|
136
|
+
merged_meta = code ? meta.merge(code: code, status: status) : meta
|
|
137
|
+
|
|
138
|
+
payload = ResponseBuilder.new(
|
|
139
|
+
success: true,
|
|
140
|
+
data: data,
|
|
141
|
+
message: message,
|
|
142
|
+
meta: merged_meta,
|
|
143
|
+
pagination: pagination,
|
|
144
|
+
request: try(:request)
|
|
145
|
+
).build
|
|
146
|
+
|
|
147
|
+
render json: payload, status: respondo_graphql_status(status)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Render a failed GraphQL response: data is null, `errors` explains why.
|
|
151
|
+
#
|
|
152
|
+
# @param message [String] human-readable error description
|
|
153
|
+
# @param errors [Hash, ActiveModel::Errors] field-level errors
|
|
154
|
+
# @param code [Integer, String, nil] HTTP code (Integer) or machine-readable code (String)
|
|
155
|
+
# @param meta [Hash] extra fields merged into extensions
|
|
156
|
+
# @param status [Symbol, Integer] HTTP status (default: :unprocessable_content / 422)
|
|
157
|
+
def render_error(message: nil, errors: nil, code: nil, meta: {}, status: :unprocessable_content)
|
|
158
|
+
merged_meta = code ? meta.merge(code: code, status: status) : meta
|
|
159
|
+
|
|
160
|
+
payload = ResponseBuilder.new(
|
|
161
|
+
success: false,
|
|
162
|
+
data: nil,
|
|
163
|
+
message: message,
|
|
164
|
+
meta: merged_meta,
|
|
165
|
+
errors: extract_errors(errors),
|
|
166
|
+
request: try(:request)
|
|
167
|
+
).build
|
|
168
|
+
|
|
169
|
+
render json: payload, status: respondo_graphql_status(status)
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# With config.always_ok every response is sent as HTTP 200.
|
|
173
|
+
def respondo_graphql_status(status)
|
|
174
|
+
RespondoGraphql.config.always_ok ? :ok : status
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Normalize errors into a plain Hash regardless of source type.
|
|
178
|
+
def extract_errors(errors)
|
|
179
|
+
return nil if errors.nil?
|
|
180
|
+
return errors.to_hash if defined?(ActiveModel::Errors) && errors.is_a?(ActiveModel::Errors)
|
|
181
|
+
return errors if errors.is_a?(Hash)
|
|
182
|
+
return { base: errors } if errors.is_a?(Array)
|
|
183
|
+
return { base: [errors] } if errors.is_a?(String)
|
|
184
|
+
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RespondoGraphql
|
|
4
|
+
# Includes RespondoGraphql::ControllerHelpers into ActionController::Base and
|
|
5
|
+
# ActionController::API so no `include` is needed in ApplicationController.
|
|
6
|
+
#
|
|
7
|
+
# Skipped when config.auto_include is false, or when the respondo (JSON) gem
|
|
8
|
+
# is also loaded — both gems define the same helper names, so the last one
|
|
9
|
+
# included would silently win in every controller. In that case include
|
|
10
|
+
# RespondoGraphql::ControllerHelpers in your GraphQL controller yourself; a
|
|
11
|
+
# module included in a subclass takes precedence over its parent's helpers.
|
|
12
|
+
class Railtie < Rails::Railtie
|
|
13
|
+
initializer "respondo_graphql.include_controller_helpers" do
|
|
14
|
+
ActiveSupport.on_load(:action_controller_base) do
|
|
15
|
+
include RespondoGraphql::ControllerHelpers if RespondoGraphql.auto_include?
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
ActiveSupport.on_load(:action_controller_api) do
|
|
19
|
+
include RespondoGraphql::ControllerHelpers if RespondoGraphql.auto_include?
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module RespondoGraphql
|
|
6
|
+
# Builds a GraphQL-spec response envelope.
|
|
7
|
+
#
|
|
8
|
+
# {
|
|
9
|
+
# errors: [ { message:, extensions: { code:, http: { status: } } } ], # only on failure
|
|
10
|
+
# data: Hash | nil,
|
|
11
|
+
# extensions: { message:, timestamp:, ...meta }
|
|
12
|
+
# }
|
|
13
|
+
#
|
|
14
|
+
# On failure `data` is null and `errors` always starts with a summary entry
|
|
15
|
+
# carrying the helper's message, followed by one entry per field-level error,
|
|
16
|
+
# so `errors[0].message` is always safe to show to a user.
|
|
17
|
+
#
|
|
18
|
+
# The extensions block always contains the message and an ISO8601 timestamp,
|
|
19
|
+
# plus request_id (config.include_request_id), config.default_meta,
|
|
20
|
+
# caller-supplied meta, pagination, and the helper's code/status.
|
|
21
|
+
class ResponseBuilder
|
|
22
|
+
# @param success [Boolean]
|
|
23
|
+
# @param data [Object] anything — serialized automatically
|
|
24
|
+
# @param message [String]
|
|
25
|
+
# @param meta [Hash] caller-supplied extra extensions (merged in)
|
|
26
|
+
# @param errors [Hash] field-level errors, e.g. { email: ["is invalid"] }
|
|
27
|
+
# @param pagination [Hash, nil] plain pagination hash supplied by the caller
|
|
28
|
+
# @param request [Object] ActionDispatch::Request (for request_id)
|
|
29
|
+
def initialize(success:, data: nil, message: nil, meta: {}, errors: nil,
|
|
30
|
+
pagination: nil, request: nil)
|
|
31
|
+
@success = success
|
|
32
|
+
@raw_data = data
|
|
33
|
+
@message = message
|
|
34
|
+
@extra_meta = meta || {}
|
|
35
|
+
@errors = errors
|
|
36
|
+
@pagination = pagination
|
|
37
|
+
@request = request
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash] the complete GraphQL response payload
|
|
41
|
+
def build
|
|
42
|
+
payload = {}
|
|
43
|
+
|
|
44
|
+
errors = build_errors
|
|
45
|
+
payload[:errors] = errors unless errors.empty?
|
|
46
|
+
|
|
47
|
+
payload[:data] = build_data
|
|
48
|
+
payload[:extensions] = { message: resolve_message }.merge(build_meta)
|
|
49
|
+
|
|
50
|
+
apply_camelize(payload)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def resolve_message
|
|
56
|
+
return @message if @message && !@message.empty?
|
|
57
|
+
|
|
58
|
+
@success ? RespondoGraphql.config.default_success_message : RespondoGraphql.config.default_error_message
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# GraphQL requires `data` to be a map (or null when execution failed).
|
|
62
|
+
def build_data
|
|
63
|
+
return nil unless @success
|
|
64
|
+
|
|
65
|
+
data = Serializer.call(@raw_data)
|
|
66
|
+
return data if data.nil? || data.is_a?(Hash)
|
|
67
|
+
|
|
68
|
+
{ RespondoGraphql.config.data_key.to_sym => data }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def build_errors
|
|
72
|
+
return [] if @success
|
|
73
|
+
|
|
74
|
+
[{ message: resolve_message, extensions: error_extensions }] + field_error_entries
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# One entry per field message, e.g. { email: ["is invalid"] } becomes
|
|
78
|
+
# { message: "email is invalid", extensions: { code: ..., field: "email" } }
|
|
79
|
+
def field_error_entries
|
|
80
|
+
return [] unless @errors.is_a?(Hash)
|
|
81
|
+
|
|
82
|
+
@errors.flat_map do |field, messages|
|
|
83
|
+
Array(messages).map do |text|
|
|
84
|
+
label = field.to_s == "base" ? text.to_s : "#{field} #{text}"
|
|
85
|
+
{ message: label, extensions: error_extensions.merge(field: field.to_s) }
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def error_extensions
|
|
91
|
+
extensions = { code: error_code }
|
|
92
|
+
extensions[:http] = { status: http_status } if http_status
|
|
93
|
+
extensions
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# A String `code:` passed to render_error is already machine-readable and
|
|
97
|
+
# is used as-is; otherwise the status symbol is used, e.g. :not_found
|
|
98
|
+
# becomes "NOT_FOUND". Falls back to "ERROR" when neither is available.
|
|
99
|
+
def error_code
|
|
100
|
+
custom = @extra_meta[:code]
|
|
101
|
+
return custom if custom.is_a?(String) && !custom.empty?
|
|
102
|
+
|
|
103
|
+
status = @extra_meta[:status]
|
|
104
|
+
status.is_a?(Symbol) || status.is_a?(String) ? status.to_s.upcase : "ERROR"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The numeric HTTP status the helper stands for, when known.
|
|
108
|
+
def http_status
|
|
109
|
+
[@extra_meta[:code], @extra_meta[:status]].find { |value| value.is_a?(Integer) }
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def build_meta
|
|
113
|
+
config = RespondoGraphql.config
|
|
114
|
+
meta = {}
|
|
115
|
+
|
|
116
|
+
# 1. Request ID first (opt-in)
|
|
117
|
+
meta[:request_id] = @request.request_id if config.include_request_id && @request.respond_to?(:request_id)
|
|
118
|
+
|
|
119
|
+
# 2. Timestamp second
|
|
120
|
+
meta[:timestamp] = current_timestamp
|
|
121
|
+
|
|
122
|
+
# 3. Global defaults (lowest priority), then caller-supplied meta
|
|
123
|
+
meta.merge!(config.default_meta)
|
|
124
|
+
meta.merge!(@extra_meta)
|
|
125
|
+
|
|
126
|
+
# 4. Pagination — plain hash from the caller
|
|
127
|
+
meta[:pagination] = @pagination if @pagination.is_a?(Hash) && !@pagination.empty?
|
|
128
|
+
|
|
129
|
+
# 5. Re-pin code and status to the very end
|
|
130
|
+
code = meta.delete(:code)
|
|
131
|
+
status = meta.delete(:status)
|
|
132
|
+
meta[:code] = code if code
|
|
133
|
+
meta[:status] = status if status
|
|
134
|
+
|
|
135
|
+
meta
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def current_timestamp
|
|
139
|
+
if Time.respond_to?(:current)
|
|
140
|
+
Time.current.iso8601
|
|
141
|
+
else
|
|
142
|
+
Time.now.utc.iso8601
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def apply_camelize(hash)
|
|
147
|
+
return hash unless RespondoGraphql.config.camelize_keys
|
|
148
|
+
|
|
149
|
+
camelize_hash(hash)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def camelize_hash(obj)
|
|
153
|
+
case obj
|
|
154
|
+
when Hash
|
|
155
|
+
obj.each_with_object({}) { |(key, value), memo| memo[camelize_key(key)] = camelize_hash(value) }
|
|
156
|
+
when Array
|
|
157
|
+
obj.map { |item| camelize_hash(item) }
|
|
158
|
+
else
|
|
159
|
+
obj
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def camelize_key(key)
|
|
164
|
+
key.to_s.gsub(/_([a-z])/) { Regexp.last_match(1).upcase }.to_sym
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RespondoGraphql
|
|
4
|
+
# Responsible for converting any Ruby object into a JSON-safe Hash or Array.
|
|
5
|
+
#
|
|
6
|
+
# Priority order:
|
|
7
|
+
# 1. Custom serializer from RespondoGraphql.config (if set)
|
|
8
|
+
# 2. ActiveRecord::Base / ActiveRecord::Relation
|
|
9
|
+
# 3. ActiveModel::Errors / objects responding to #errors
|
|
10
|
+
# 4. Objects responding to #as_json or #to_h
|
|
11
|
+
# 5. Arrays — each element is serialized recursively
|
|
12
|
+
# 6. Primitives — returned as-is
|
|
13
|
+
module Serializer
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# @param object [Object] anything — AR model, collection, error, hash, array, primitive
|
|
17
|
+
# @return [Hash, Array, String, Numeric, nil]
|
|
18
|
+
def call(object)
|
|
19
|
+
return nil if object.nil?
|
|
20
|
+
|
|
21
|
+
# 1. Custom serializer wins
|
|
22
|
+
if RespondoGraphql.config.serializer
|
|
23
|
+
return RespondoGraphql.config.serializer.call(object)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# 2. ActiveRecord::Relation or any object with #map (lazy collections)
|
|
27
|
+
if ar_relation?(object)
|
|
28
|
+
return object.map { |record| serialize_record(record) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# 3. Array — recurse
|
|
32
|
+
if object.is_a?(Array)
|
|
33
|
+
return object.map { |item| call(item) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# 4. ActiveRecord model instance
|
|
37
|
+
if ar_model?(object)
|
|
38
|
+
return serialize_record(object)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 5. ActiveModel::Errors object
|
|
42
|
+
if active_model_errors?(object)
|
|
43
|
+
return serialize_errors(object)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# 6. Exception / StandardError
|
|
47
|
+
if object.is_a?(Exception)
|
|
48
|
+
return { message: object.message }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# 7. Hash — recursively serialize values
|
|
52
|
+
if object.is_a?(Hash)
|
|
53
|
+
return object.transform_values { |v| call(v) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# 8. Responds to #as_json (ActiveSupport)
|
|
57
|
+
if object.respond_to?(:as_json)
|
|
58
|
+
return object.as_json
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# 9. Responds to #to_h
|
|
62
|
+
if object.respond_to?(:to_h)
|
|
63
|
+
return object.to_h
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# 10. Primitive — return as-is
|
|
67
|
+
object
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
module_function
|
|
73
|
+
|
|
74
|
+
def ar_relation?(object)
|
|
75
|
+
defined?(ActiveRecord::Relation) &&
|
|
76
|
+
object.is_a?(ActiveRecord::Relation)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def ar_model?(object)
|
|
80
|
+
defined?(ActiveRecord::Base) &&
|
|
81
|
+
object.is_a?(ActiveRecord::Base)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def active_model_errors?(object)
|
|
85
|
+
defined?(ActiveModel::Errors) &&
|
|
86
|
+
object.is_a?(ActiveModel::Errors)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def serialize_record(record)
|
|
90
|
+
if record.respond_to?(:as_json)
|
|
91
|
+
record.as_json
|
|
92
|
+
elsif record.respond_to?(:to_h)
|
|
93
|
+
record.to_h
|
|
94
|
+
else
|
|
95
|
+
record
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def serialize_errors(errors)
|
|
100
|
+
# ActiveModel::Errors — return { field: ["msg", ...] }
|
|
101
|
+
if errors.respond_to?(:to_hash)
|
|
102
|
+
errors.to_hash
|
|
103
|
+
elsif errors.respond_to?(:messages)
|
|
104
|
+
errors.messages
|
|
105
|
+
else
|
|
106
|
+
{}
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "respondo_graphql/version"
|
|
4
|
+
require_relative "respondo_graphql/configuration"
|
|
5
|
+
require_relative "respondo_graphql/serializer"
|
|
6
|
+
require_relative "respondo_graphql/response_builder"
|
|
7
|
+
require_relative "respondo_graphql/controller_helpers"
|
|
8
|
+
|
|
9
|
+
# RespondoGraphql — consistent GraphQL-spec responses for Rails controllers.
|
|
10
|
+
#
|
|
11
|
+
# @example Configure once in an initializer
|
|
12
|
+
# # config/initializers/respondo_graphql.rb
|
|
13
|
+
# RespondoGraphql.configure do |config|
|
|
14
|
+
# config.camelize_keys = true
|
|
15
|
+
# config.always_ok = true # HTTP 200 for every response
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# @example Use in a controller
|
|
19
|
+
# class GraphqlController < ApplicationController
|
|
20
|
+
# def show
|
|
21
|
+
# render_ok(data: { user: User.find(params[:id]) }, message: "User fetched")
|
|
22
|
+
# end
|
|
23
|
+
# end
|
|
24
|
+
#
|
|
25
|
+
# @example Include manually (outside Rails, or next to the respondo JSON gem)
|
|
26
|
+
# class GraphqlController < ApplicationController
|
|
27
|
+
# include RespondoGraphql::ControllerHelpers
|
|
28
|
+
# end
|
|
29
|
+
module RespondoGraphql
|
|
30
|
+
class << self
|
|
31
|
+
# @return [RespondoGraphql::Configuration]
|
|
32
|
+
def config
|
|
33
|
+
@config ||= Configuration.new
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @yield [RespondoGraphql::Configuration]
|
|
37
|
+
def configure
|
|
38
|
+
yield config
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Reset config (useful in tests)
|
|
42
|
+
def reset!
|
|
43
|
+
@config = Configuration.new
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Whether the Railtie should include the helpers into the base controllers.
|
|
47
|
+
# False when the user turned it off, or when the respondo (JSON) gem is
|
|
48
|
+
# loaded too (both define the same helper names).
|
|
49
|
+
def auto_include?
|
|
50
|
+
config.auto_include && !defined?(::Respondo::ControllerHelpers) ? true : false
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Auto-integrate with Rails if present
|
|
56
|
+
require_relative "respondo_graphql/railtie" if defined?(Rails::Railtie)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "lib/respondo_graphql/version"
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = "respondo-graphql"
|
|
7
|
+
spec.version = RespondoGraphql::VERSION
|
|
8
|
+
spec.authors = ["shailendra Kumar"]
|
|
9
|
+
spec.email = ["shailendrapatidar00@gmail.com"]
|
|
10
|
+
|
|
11
|
+
spec.summary = "Consistent GraphQL-spec responses for Rails controllers — data, errors, extensions."
|
|
12
|
+
spec.description = <<~DESC
|
|
13
|
+
RespondoGraphql standardizes the GraphQL-style responses your Rails
|
|
14
|
+
controllers send: data, errors and extensions, built from one set of
|
|
15
|
+
expressive helpers (render_ok, render_not_found, render_unprocessable, ...).
|
|
16
|
+
Field errors become spec-compliant errors[] entries, message and meta live in
|
|
17
|
+
extensions, and HTTP statuses can optionally be pinned to 200.
|
|
18
|
+
Standalone gem — it does not depend on the respondo (JSON) gem.
|
|
19
|
+
DESC
|
|
20
|
+
spec.homepage = "https://github.com/spatelpatidar/respondo-graphql"
|
|
21
|
+
spec.license = "MIT"
|
|
22
|
+
spec.required_ruby_version = ">= 3.0.0"
|
|
23
|
+
|
|
24
|
+
spec.metadata = {
|
|
25
|
+
"homepage_uri" => spec.homepage,
|
|
26
|
+
"source_code_uri" => spec.homepage,
|
|
27
|
+
"changelog_uri" => "#{spec.homepage}/blob/main/CHANGELOG.md",
|
|
28
|
+
"bug_tracker_uri" => "#{spec.homepage}/issues",
|
|
29
|
+
"rubygems_mfa_required" => "true"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE.txt", "CHANGELOG.md", "respondo-graphql.gemspec"]
|
|
33
|
+
spec.require_paths = ["lib"]
|
|
34
|
+
|
|
35
|
+
spec.add_development_dependency "railties", "~> 8.1.3"
|
|
36
|
+
spec.add_development_dependency "rspec", "~> 3.12"
|
|
37
|
+
spec.add_development_dependency "rake", "~> 13.0"
|
|
38
|
+
spec.add_development_dependency "activesupport", "~> 8.1.3"
|
|
39
|
+
spec.add_development_dependency "simplecov", "~> 0.22"
|
|
40
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: respondo-graphql
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- shailendra Kumar
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-10-21 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: railties
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - "~>"
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: 8.1.3
|
|
20
|
+
type: :development
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - "~>"
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: 8.1.3
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rspec
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - "~>"
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '3.12'
|
|
34
|
+
type: :development
|
|
35
|
+
prerelease: false
|
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - "~>"
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: '3.12'
|
|
41
|
+
- !ruby/object:Gem::Dependency
|
|
42
|
+
name: rake
|
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
|
44
|
+
requirements:
|
|
45
|
+
- - "~>"
|
|
46
|
+
- !ruby/object:Gem::Version
|
|
47
|
+
version: '13.0'
|
|
48
|
+
type: :development
|
|
49
|
+
prerelease: false
|
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
51
|
+
requirements:
|
|
52
|
+
- - "~>"
|
|
53
|
+
- !ruby/object:Gem::Version
|
|
54
|
+
version: '13.0'
|
|
55
|
+
- !ruby/object:Gem::Dependency
|
|
56
|
+
name: activesupport
|
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
|
58
|
+
requirements:
|
|
59
|
+
- - "~>"
|
|
60
|
+
- !ruby/object:Gem::Version
|
|
61
|
+
version: 8.1.3
|
|
62
|
+
type: :development
|
|
63
|
+
prerelease: false
|
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
65
|
+
requirements:
|
|
66
|
+
- - "~>"
|
|
67
|
+
- !ruby/object:Gem::Version
|
|
68
|
+
version: 8.1.3
|
|
69
|
+
- !ruby/object:Gem::Dependency
|
|
70
|
+
name: simplecov
|
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
|
72
|
+
requirements:
|
|
73
|
+
- - "~>"
|
|
74
|
+
- !ruby/object:Gem::Version
|
|
75
|
+
version: '0.22'
|
|
76
|
+
type: :development
|
|
77
|
+
prerelease: false
|
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
79
|
+
requirements:
|
|
80
|
+
- - "~>"
|
|
81
|
+
- !ruby/object:Gem::Version
|
|
82
|
+
version: '0.22'
|
|
83
|
+
description: |
|
|
84
|
+
RespondoGraphql standardizes the GraphQL-style responses your Rails
|
|
85
|
+
controllers send: data, errors and extensions, built from one set of
|
|
86
|
+
expressive helpers (render_ok, render_not_found, render_unprocessable, ...).
|
|
87
|
+
Field errors become spec-compliant errors[] entries, message and meta live in
|
|
88
|
+
extensions, and HTTP statuses can optionally be pinned to 200.
|
|
89
|
+
Standalone gem — it does not depend on the respondo (JSON) gem.
|
|
90
|
+
email:
|
|
91
|
+
- shailendrapatidar00@gmail.com
|
|
92
|
+
executables: []
|
|
93
|
+
extensions: []
|
|
94
|
+
extra_rdoc_files: []
|
|
95
|
+
files:
|
|
96
|
+
- CHANGELOG.md
|
|
97
|
+
- README.md
|
|
98
|
+
- lib/generators/respondo_graphql/install/install_generator.rb
|
|
99
|
+
- lib/respondo-graphql.rb
|
|
100
|
+
- lib/respondo_graphql.rb
|
|
101
|
+
- lib/respondo_graphql/configuration.rb
|
|
102
|
+
- lib/respondo_graphql/controller_helpers.rb
|
|
103
|
+
- lib/respondo_graphql/railtie.rb
|
|
104
|
+
- lib/respondo_graphql/response_builder.rb
|
|
105
|
+
- lib/respondo_graphql/serializer.rb
|
|
106
|
+
- lib/respondo_graphql/version.rb
|
|
107
|
+
- respondo-graphql.gemspec
|
|
108
|
+
homepage: https://github.com/spatelpatidar/respondo-graphql
|
|
109
|
+
licenses:
|
|
110
|
+
- MIT
|
|
111
|
+
metadata:
|
|
112
|
+
homepage_uri: https://github.com/spatelpatidar/respondo-graphql
|
|
113
|
+
source_code_uri: https://github.com/spatelpatidar/respondo-graphql
|
|
114
|
+
changelog_uri: https://github.com/spatelpatidar/respondo-graphql/blob/main/CHANGELOG.md
|
|
115
|
+
bug_tracker_uri: https://github.com/spatelpatidar/respondo-graphql/issues
|
|
116
|
+
rubygems_mfa_required: 'true'
|
|
117
|
+
post_install_message:
|
|
118
|
+
rdoc_options: []
|
|
119
|
+
require_paths:
|
|
120
|
+
- lib
|
|
121
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
122
|
+
requirements:
|
|
123
|
+
- - ">="
|
|
124
|
+
- !ruby/object:Gem::Version
|
|
125
|
+
version: 3.0.0
|
|
126
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - ">="
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '0'
|
|
131
|
+
requirements: []
|
|
132
|
+
rubygems_version: 3.4.10
|
|
133
|
+
signing_key:
|
|
134
|
+
specification_version: 4
|
|
135
|
+
summary: Consistent GraphQL-spec responses for Rails controllers — data, errors, extensions.
|
|
136
|
+
test_files: []
|