RailsでURL文字列にaタグに変換する

railsのメール処理で文字列に含まれるURLをaタグ付きに変換したいことありました。

ちょっと調べてみると、`URI.extract`を使うと文字列のURLが簡単に取得できる。。。割と簡単にできそうやん、と思って書いてみたら、実は罠が結構あって嵌ってしまったので復習がてら書いてみることにしました。

## TL;DR
最終的なコードは下記にすることで解決しました。どうやってこれにたどり着いたのか？なぜこうすると良いのかを後述して行きます。

```ruby
def convert_url_to_a_element(text)
  uri_reg = URI.regexp(%w[http https])
  text.gsub(uri_reg) { %{<a href='#{$&}' target='_blank'>#{$&}</a>} }
end

text = 'url1: http://hogehoge.com/hoge url2: http://hogehoge.com/fuga'
convert_url_to_a_element(text)
=> "url1: <a href='http://hogehoge.com/hoge' target='_blank'>http://hogehoge.com/hoge</a> url2: <a href='http://hogehoge.com/fuga' target='_blank'>http://hogehoge.com/fuga</a>"
```

## アンチパターン
まずは最初に間違っていた処理の書き方です。
とはいえ、これでも下記のようなテキストであれば問題なく処理ができてしまいます。だからこそ今回すぐにこの書き方の罠に気づくことができていませんでした。。。

```ruby
def convert_url_to_a_element(text)
  URI.extract(text, %w[http https]).uniq.each do |url|
    sub_text = "<a href='#{url}' target='_blank'>#{url}</a>"
    text.gsub(url, sub_text)
  end
  text
end

text = 'url1: http://hogehoge.com url2: http://fugafuga.com'
convert_url_to_a_element(text)
=> 'url1: http://hogehoge.com url2: http://fugafuga.com'
```

`URI.extract`を使うと下記のようにURL形式の文字列を全て取得することができる。

```ruby
text = 'url1: http://hogehoge.com url2: http://fugafuga.com'
URI.extract(text, %w[http https])
=> ["http://hogehoge.com", "http://fugafuga.com"]
```

これをeachで回して置換しています。しかしながら、下記のように同じドメイン名のURL２種類で実施すると。。。

```ruby
text = 'url1: http://hogehoge.com/hoge url2: http://hogehoge.com'
convert_url_to_a_element(text)
=> "url1: <a href='<a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>/hoge' target='_blank'><a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>/hoge</a> url2: <a href='http://hogehoge.com' target='_blank'>http://hogehoge.com</a>"
```
なんかめっちゃ崩れてる。。。

## 原因
原因は、2回目の置換にてaタグ変換後のテキストに対しても置換処理を行ってしまったためです。
このように、上記の書き方では**同一ホスト名のURLが2つ以上あるとうまく動作しない**という落とし穴があります。

## 対応策
`URI.extract`で取得した文字列をeachで回すのではなく、正規表現を取得してgsubのパターンに正規表現を使って置換させることで、二重置換を防ぐことができます。

```ruby
def convert_url_to_a_element(text)
  uri_reg = URI.regexp(%w[http https])
  text.gsub(uri_reg) { %{<a href='#{$&}' target='_blank'>#{$&}</a>} }
end
```

## 補足メモ
### URI.regexpについて
`URI.regexp`は指定したスキーマのURL文字列のパターンを正規表現で返すメソッドです。正規表現とは、文字列ものなので、自分で書くことも可能ですがそれをサクッと作ってくれるのがこのメソッドです。


返り値をみるとわかると思いますが、これを自分で１から書く気にはなれませんでした。。。

```ruby
URI.regexp(%w[http https])
=> /(?=(?-mix:http|https):)
        ([a-zA-Z][\-+.a-zA-Z\d]*):                           (?# 1: scheme)
        (?:
           ((?:[\-_.!~*'()a-zA-Z\d;?:@&=+$,]|%[a-fA-F\d]{2})(?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*)                    (?# 2: opaque)
        |
           (?:(?:
             \/\/(?:
                 (?:(?:((?:[\-_.!~*'()a-zA-Z\d;:&=+$,]|%[a-fA-F\d]{2})*)@)?        (?# 3: userinfo)
                   (?:((?:(?:[a-zA-Z0-9\-.]|%\h\h)+|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}|\[(?:(?:[a-fA-F\d]{1,4}:)*(?:[a-fA-F\d]{1,4}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})|(?:(?:[a-fA-F\d]{1,4}:)*[a-fA-F\d]{1,4})?::(?:(?:[a-fA-F\d]{1,4}:)*(?:[a-fA-F\d]{1,4}|\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}))?)\]))(?::(\d*))?))? (?# 4: host, 5: port)
               |
                 ((?:[\-_.!~*'()a-zA-Z\d$,;:@&=+]|%[a-fA-F\d]{2})+)                 (?# 6: registry)
               )
             |
             (?!\/\/))                           (?# XXX: '\/\/' is the mark for hostport)
             (\/(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*(?:;(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*)*(?:\/(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*(?:;(?:[\-_.!~*'()a-zA-Z\d:@&=+$,]|%[a-fA-F\d]{2})*)*)*)?                    (?# 7: path)
           )(?:\?((?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*))?                 (?# 8: query)
        )
        (?:\#((?:[\-_.!~*'()a-zA-Z\d;\/?:@&=+$,\[\]]|%[a-fA-F\d]{2})*))?                  (?# 9: fragment)
      /x
```

### gsubについて
gsubメソッド自体は正規表現ではなく文字列を渡しても置換することができます。前者の場合では単純に取得したURL文字列をeachで渡して置換しているのですが、その結果、同じドメインが含まれるURLなんかだと、aタグ変換後の文字列に対しても置換処理が実行されてしまい、変な文字列になってしまうようです。

考えてみりゃそりゃそうか。。。って感じですがこの対策が案外思いつかなくて悩みました。まずはgsub

```ruby
text.gsub!(uri_reg) { %{<a href="#{$&}">#{$&}</a>} }
```

### URI.extractについて
まず、最初に使った`URI.extract`だが、スキーマを指定することでテキスト内からURL文字列のみを取得することができる。今回は最終的には使わなかったが、URL文字列のみをシンプルに取得したいのであれば便利そうでした。

```ruby
text = 'aaaaa http://xxx.com/hoge bbbbb http://xxx.com'
URI.extract(text, %w[http https])
=> ["http://xxx.com/hoge" "http://xxx.com"]
```

## まとめ
* aタグ変換を行うのであればgsubも正規表現でパターンマッチングした上で置換した方が良さそう
* 正規表現そのものは`URI.regexp`を使うと簡単に取得することができる

と、紆余曲折ありましたが良いコードになったんじゃないかと思います。
もっと他に良い書き方があったりしたら是非とも教えていただきたいです。

## 参考URL
+ [URI\.extract \(Ruby 2\.7\.0 リファレンスマニュアル\)](https://docs.ruby-lang.org/ja/latest/method/URI/s/extract.html)
+ [URI\.regexp \(Ruby 2\.7\.0 リファレンスマニュアル\)](https://docs.ruby-lang.org/ja/latest/method/URI/s/regexp.html)
+ [String\#gsub \(Ruby 2\.7\.0 リファレンスマニュアル\)](https://docs.ruby-lang.org/ja/latest/method/String/i/gsub.html)