LCOV - code coverage report
Current view: top level - adapter/http/middleware - camel_snake_codec.go Coverage Total Hit
Test: coverage.lcov Lines: 63.5 % 189 120
Test Date: 2026-04-14 06:42:22 Functions: - 0 0

            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              : }
        

Generated by: LCOV version 2.3.1-1