Line data Source code
1 : // Package middleware presenter/http/camel_snake_codec.go
2 : package middleware
3 :
4 : import (
5 : "bytes"
6 : "encoding/json"
7 : "io"
8 : "log"
9 : "net/http"
10 : "strconv"
11 : "strings"
12 : "unicode"
13 :
14 : "github.com/gin-gonic/gin"
15 : )
16 :
17 : // ContextKeySkipCodec コンテキストに true をセットすると、このミドルウェアは処理をスキップします。
18 : //
19 : // c.Set(ContextKeySkipCodec, true)
20 : const ContextKeySkipCodec = "skipCamelSnakeCodec"
21 :
22 : // CamelSnakeOptions は camelCase/snake_case の相互変換ミドルウェアの動作を制御します。
23 : type CamelSnakeOptions struct {
24 : EnableRequestDecode bool // Request: camelCase -> snake_case
25 : EnableResponseEncode bool // Response: snake_case -> camelCase
26 : }
27 :
28 2 : func defaultOptions() CamelSnakeOptions {
29 2 : return CamelSnakeOptions{
30 2 : EnableRequestDecode: true,
31 2 : EnableResponseEncode: true,
32 2 : }
33 2 : }
34 :
35 : // CamelSnakeCodec は JSON のキーをリクエストで snake_case に、レスポンスで camelCase に変換する Gin ミドルウェアを返します。
36 : // オプションで各方向の変換を有効/無効にできます。
37 2 : func CamelSnakeCodec(opts ...CamelSnakeOptions) gin.HandlerFunc {
38 2 : opt := defaultOptions()
39 2 : if len(opts) > 0 {
40 0 : opt = opts[0]
41 0 : }
42 :
43 4 : return func(c *gin.Context) {
44 2 : if skip, ok := c.Get(ContextKeySkipCodec); ok {
45 0 : if b, ok := skip.(bool); ok && b {
46 0 : c.Next()
47 0 : return
48 0 : }
49 : }
50 :
51 : // ---- Query: camelCase -> snake_case ----
52 2 : q := c.Request.URL.Query()
53 2 : changed := false
54 2 : for key, vals := range q {
55 0 : snake := camelToSnake(key)
56 0 : if snake != key {
57 0 : if _, exists := q[snake]; !exists {
58 0 : q[snake] = vals
59 0 : changed = true
60 0 : }
61 : }
62 : }
63 2 : if changed {
64 0 : c.Request.URL.RawQuery = q.Encode()
65 0 : }
66 :
67 : // ---- Request: camelCase -> snake_case ----
68 2 : if opt.EnableRequestDecode && isJSONContentType(c.Request.Header.Get("Content-Type")) &&
69 3 : c.Request.Body != nil && c.Request.ContentLength != 0 && c.Request.Method != http.MethodGet {
70 1 : bodyBytes, err := io.ReadAll(c.Request.Body)
71 2 : if err == nil && len(bytes.TrimSpace(bodyBytes)) > 0 {
72 1 : var any interface{}
73 2 : if json.Unmarshal(bodyBytes, &any) == nil {
74 1 : any = convertKeysCamelToSnake(any)
75 2 : if patched, err := json.Marshal(any); err == nil {
76 1 : c.Request.Body = io.NopCloser(bytes.NewReader(patched))
77 1 : c.Request.ContentLength = int64(len(patched))
78 1 : c.Request.Header.Del("Transfer-Encoding")
79 1 : } else {
80 0 : // 失敗時は元ボディを戻す
81 0 : c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
82 0 : }
83 0 : } else {
84 0 : // JSONでなければ元ボディを戻す
85 0 : c.Request.Body = io.NopCloser(bytes.NewReader(bodyBytes))
86 0 : }
87 : }
88 : }
89 :
90 : // ---- Response: snake_case -> camelCase ----
91 2 : bw := &bufferedWriter{ResponseWriter: c.Writer}
92 2 : c.Writer = bw
93 2 :
94 2 : c.Next()
95 2 :
96 2 : status := bw.Status()
97 2 : if status == http.StatusNoContent || status == http.StatusNotModified || c.Request.Method == http.MethodHead {
98 0 : return
99 0 : }
100 :
101 2 : ct := bw.Header().Get("Content-Type")
102 2 : ce := bw.Header().Get("Content-Encoding")
103 3 : if bw.buf.Len() == 0 || !isJSONContentType(ct) || ce != "" || !opt.EnableResponseEncode {
104 2 : if bw.buf.Len() > 0 {
105 1 : writeRaw(bw, bw.buf.Bytes())
106 1 : }
107 1 : return
108 : }
109 :
110 2 : original := bw.buf.Bytes()
111 2 : var any interface{}
112 2 : if err := json.Unmarshal(original, &any); err != nil {
113 0 : // JSONじゃなければそのまま
114 0 : writeRaw(bw, original)
115 0 : return
116 0 : }
117 2 : any = convertKeysSnakeToCamel(any)
118 2 : modified, err := json.Marshal(any)
119 2 : if err != nil {
120 0 : // 変換失敗時はそのまま
121 0 : writeRaw(bw, original)
122 0 : return
123 0 : }
124 :
125 2 : bw.Header().Set("Content-Length", strconv.Itoa(len(modified)))
126 2 : bw.Header().Del("Transfer-Encoding")
127 2 : if _, err := bw.ResponseWriter.Write(modified); err != nil {
128 0 : // ここで失敗してもやれることは少ないのでログのみ
129 0 : log.Printf("camel_snake_codec: write modified response failed: %v", err)
130 0 : }
131 : }
132 : }
133 :
134 : // ---- Response Writer ラッパ ----
135 :
136 : type bufferedWriter struct {
137 : gin.ResponseWriter
138 : buf bytes.Buffer
139 : }
140 :
141 2 : func (w *bufferedWriter) Write(b []byte) (int, error) {
142 2 : // ここではバッファに溜めるだけ。実書き込みはミドルウェアの後段で一度だけ行う。
143 2 : return w.buf.Write(b)
144 2 : }
145 :
146 : // 元の ResponseWriter に raw で書く(バッファは使わない)
147 1 : func writeRaw(bw *bufferedWriter, b []byte) {
148 1 : if len(b) == 0 {
149 0 : return
150 0 : }
151 1 : bw.Header().Set("Content-Length", strconv.Itoa(len(b)))
152 1 : bw.Header().Del("Transfer-Encoding")
153 1 : if _, err := bw.ResponseWriter.Write(b); err != nil {
154 0 : log.Printf("camel_snake_codec: write raw response failed: %v", err)
155 0 : }
156 : }
157 :
158 : // ---- ユーティリティ ----
159 :
160 2 : func isJSONContentType(ct string) bool {
161 3 : if ct == "" {
162 1 : return false
163 1 : }
164 2 : return strings.HasPrefix(strings.ToLower(strings.TrimSpace(ct)), "application/json")
165 : }
166 :
167 2 : func convertKeysSnakeToCamel(v interface{}) interface{} {
168 2 : switch vv := v.(type) {
169 2 : case map[string]interface{}:
170 2 : res := make(map[string]interface{}, len(vv))
171 4 : for k, val := range vv {
172 2 : res[snakeToCamel(k)] = convertKeysSnakeToCamel(val)
173 2 : }
174 2 : return res
175 0 : case []interface{}:
176 0 : for i := range vv {
177 0 : vv[i] = convertKeysSnakeToCamel(vv[i])
178 0 : }
179 0 : return vv
180 2 : default:
181 2 : return v
182 : }
183 : }
184 :
185 1 : func convertKeysCamelToSnake(v interface{}) interface{} {
186 1 : switch vv := v.(type) {
187 1 : case map[string]interface{}:
188 1 : res := make(map[string]interface{}, len(vv))
189 2 : for k, val := range vv {
190 1 : res[camelToSnake(k)] = convertKeysCamelToSnake(val)
191 1 : }
192 1 : return res
193 0 : case []interface{}:
194 0 : for i := range vv {
195 0 : vv[i] = convertKeysCamelToSnake(vv[i])
196 0 : }
197 0 : return vv
198 1 : default:
199 1 : return v
200 : }
201 : }
202 :
203 : // lowerCamel に揃える。Builder の戻り値を必ずチェックする。
204 2 : func snakeToCamel(s string) string {
205 2 : if s == "" {
206 0 : return s
207 0 : }
208 2 : var b strings.Builder
209 2 : upperNext := false
210 4 : for i, r := range s {
211 3 : if r == '_' || r == '-' {
212 1 : upperNext = true
213 1 : continue
214 : }
215 4 : if i == 0 {
216 2 : if ok := safeWriteRune(&b, unicode.ToLower(r)); !ok {
217 0 : return s
218 0 : }
219 2 : continue
220 : }
221 3 : if upperNext {
222 1 : if ok := safeWriteRune(&b, unicode.ToUpper(r)); !ok {
223 0 : return s
224 0 : }
225 1 : upperNext = false
226 2 : } else {
227 2 : if ok := safeWriteRune(&b, r); !ok {
228 0 : return s
229 0 : }
230 : }
231 : }
232 2 : return b.String()
233 : }
234 :
235 1 : func camelToSnake(s string) string {
236 1 : if s == "" {
237 0 : return s
238 0 : }
239 1 : var b strings.Builder
240 2 : for i, r := range s {
241 2 : if unicode.IsUpper(r) {
242 2 : if i > 0 {
243 1 : if ok := safeWriteByte(&b, '_'); !ok {
244 0 : return s
245 0 : }
246 : }
247 1 : if ok := safeWriteRune(&b, unicode.ToLower(r)); !ok {
248 0 : return s
249 0 : }
250 1 : } else if r == '-' {
251 0 : if ok := safeWriteByte(&b, '_'); !ok {
252 0 : return s
253 0 : }
254 1 : } else {
255 1 : if ok := safeWriteRune(&b, r); !ok {
256 0 : return s
257 0 : }
258 : }
259 : }
260 1 : return b.String()
261 : }
262 :
263 : // ---- Builder 安全書き込みヘルパ ----
264 :
265 2 : func safeWriteRune(b *strings.Builder, r rune) bool {
266 2 : _, err := b.WriteRune(r)
267 2 : return err == nil
268 2 : }
269 :
270 1 : func safeWriteByte(b *strings.Builder, by byte) bool {
271 1 : if err := b.WriteByte(by); err != nil {
272 0 : return false
273 0 : }
274 1 : return true
275 : }
|