Madogiwa Blog

主に技術系の学習メモに使っていきます。

Ruby on Rails: layoutsをviews配下のディレクトリ(ex. admin, api)単位で持つ方法メモ📝

Ruby on Railsで管理画面とユーザーが閲覧する画面でlayoutを分けるようなケースが一定あると思いますが、 その際に以下のようにlayouts配下にディレクトリを切っていくとApplicationControllerAdmin::ApplicationControllerでControllerを分けた際にRailsがよしなにlayouts配下のapplicaiton.html.erbadmin/application.html.erbを利用してくれるので便利です。

$ tree app/views
app/views
├── layouts
 │       ├── application.html.erb
 │       └─admin
 │         └── application.html.erb

2.2.14 Finding Layouts
To find the current layout, Rails first looks for a file in app/views/layouts with the same base name as the controller. For example, rendering actions from the PhotosController class will use app/views/layouts/photos.html.erb (or app/views/layouts/photos.builder).
Layouts and Rendering in Rails — Ruby on Rails Guides

しかし、だんだん大きくなってくると全てのレイアウトがlayoutsファイルに溜まっていきどこで利用されているものか分かりにくくなったりするので、以下のような感じでトップレベルのディレクトリ単位で分ける方法をメモ🗒

$ tree app/views
app/views
├── admin
│   └── layouts
│       └── application.html.erb
├── layouts
│       └── application.html.erb

やり方は簡単でController内でlaytoutを使って明示的に指定してあげれば大丈夫だった🙆‍♂️

# frozen_string_literal: true

class Admin::ApplicationController < ApplicationController
  layout 'admin/layouts/application'
end

Ruby on Rails便利ですね!

ポチポチしてRails newコマンドを作れるツールをAstro on Vue.jsで作った🚃

タイトル通り、ポチポチしてRails newコマンドを作れるツールをAstro on Vue.jsで作った🧑‍🏭

madogiwa0124.github.io

機能的にはrails newで指定できる各optionをUI上からポチポチして以下のようなコマンドをコピーできるやつです。

$ rails new app_name --database=postgresql --skip-keeps --skip-action-mailer --skip-action-mailbox --skip-action-text --skip-active-storage --skip-action-cable --skip-asset-pipeline

技術的にはAstro上でVue.jsを使ってみました。

github.com

初めてAstroを使ってみたのですが、

astro.build

GitHub Pagesにも簡単にデプロイ出来て体験が良かったので静的ホスティングする系のものを作る時にはこれからも使っていきたい🚀

docs.astro.build

Ruby on Rails: Gem等の外部から`config.x`を使わずにネストしたカスタム設定を追加する方法MEMO📝

Ruby on Railsを拡張するGemを作成する等、デフォルトの振る舞いといったものRails.application.configureで設定できるようにしたいなぁと思ったのですが、config.xは使わずにネストしたカスタム設定をいい感じに追加する方法をメモ🗒

Railsガイドを見ると以下のような記載があり、基本的にはconfig.fooの形で直接記載すればいいのですがネストする場合にはhashのkeyとしてアクセスしないといけないので、そういう場合にはconfig.x.fooみたいな形で定義してあげるのが推奨なのですが、Gem側等の外部からの設定でconfig.xを使うのはちょっとイマイチかなとも思ったのでいい感じに実装できないのかなぁと思ったのですが、

Railsの設定オブジェクトに独自のコードを設定するには、config.x名前空間またはconfigに直接コードを書きます。両者の重要な違いは、ネストした設定(config.x.nested.nested.hiなど)の場合はconfig.xを使うべきで、単一レベルの設定(config.helloなど)ではconfigだけを使うべきであるという点です。 Rails アプリケーションを設定する - Railsガイド

以下のようなコードを用意して、initializer等々で初期化時に読み込んであげればconfig.fooの形式でネストしたカスタム設定をいい感じに追加できそうでした🙆‍♂️

# frozen_string_literal: true

require 'active_support'
require 'active_support/ordered_options'
require 'rails/railtie'

module Foo
  class Configration < ActiveSupport::OrderedOptions
    def custom_payload(&block)
      self.custom_payload_method = block
    end
  end

  class Railtie < Rails::Railtie
    config.foo = Configration.new

    config.after_initialize do |_app|
      # NOTE: ユーザーが書き換えた設定値に依存した処理はinitializerによる設定が行われた後に実行するためにここで読み込むなり実行するなりする。
    end
  end
end

Hashを継承した動的にaccessor メソッドが定義されるActiveSupport::OrderedOptionsインスタンスをtopレベルの名前空間config.fooに渡すことでネストした値でもいい感じにkey形式じゃなくても取得できるようになるということですね✨

ActiveSupport::OrderedOptions < Hash OrderedOptions inherits from Hash and provides dynamic accessor methods. ActiveSupport::OrderedOptions

config.xの実態であるRails::Application::Configuration::Custommethod_missingActiveSupport::OrderedOptions.newが設定されるようになってるんですね。

module Rails
  class Application
    class Configuration
      class Custom # :nodoc:
        def initialize
          @configurations = Hash.new
        end

        def method_missing(method, *args)
          if method.end_with?("=")
            @configurations[:"#{method[0..-2]}"] = args.first
          else
            @configurations.fetch(method) {
              @configurations[method] = ActiveSupport::OrderedOptions.new
            }
          end
        end

        def respond_to_missing?(symbol, *)
          true
        end
      end

rails/configuration.rb at v7.0.4.3 · rails/rails · GitHub

便利!

参考

github.com

railsguides.jp

Vue.js: `@rails/ujs`を利用した`link_to`の`method`属性によるhttp method指定を再現してみたのでメモ📝

Ruby on Railslink_toには、methodを指定してGET以外のリクエストを送信する機能があります。Viewを含めてフロント周りをVue.jsに置き換えるときに、この辺りの再現がネックになったのでVue.jsでの再現方法をメモ📝

You can bind to the same Ajax events as form_with. Here's an example. Let's assume that we have a list of articles that can be deleted with just one click. We would generate some HTML like this:

 <%= link_to "Delete article", @article, remote: true, method: :delete %>

Working with JavaScript in Rails — Ruby on Rails Guides

前提事項

以下の記事で記載したFormコンポーネントが存在することが前提です。

madogiwa0124.hatenablog.com

<template>
  <form ref="form" class="common-form" :action="requestPath" accept-charset="UTF-8" :method="formMethod">
    <input type="hidden" name="_method" :value="requestMethod" autocomplete="off" />
    <input type="hidden" name="authenticity_token" :value="authenticityToken" autocomplete="off" />
    <slot />
  </form>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
type Props = {
  requestMethod: "get" | "post" | "patch" | "put" | "delete";
  requestPath: string;
};
const csrfToken = () => {
  const meta = document.querySelector("meta[name=csrf-token]");
  return meta && (meta as HTMLMetaElement).content;
};
const props = defineProps<Props>();
const authenticityToken = csrfToken();
const formMethod = computed(() => (props.requestMethod === "get" ? "get" : "post"));
const form = ref<HTMLFormElement | null>(null);
defineExpose({ form });
</script>
<style lang="scss" scoped></style>

実装したコード

以下のようなコンポーネントを用意して再現してみました。仕組みはget以外の場合には前提に記載したCommonFormを非表示要素として入れて、 aタグクリックのeventをpreventし、Formをsubmitイベントを呼び出すようにしています。

<template>
  <a v-if="method === 'get'" class="common-link" :href="href">
    <slot />
  </a>
  <a v-else class="common-link" @click.prevent="handleOnSubmit">
    <slot />
    <CommonForm ref="linkForm" class="common-link__link-form" :request-method="method" :request-path="href" />
  </a>
</template>
<script lang="ts" setup>
import type { HttpRequestMethod } from "@js/types/types";
import CommonForm from "@js/components/molecules/CommonForm.vue";
import { ref } from "vue";

type PropsType = { href: string; method?: HttpRequestMethod };
withDefaults(defineProps<PropsType>(), { method: "get" });
const linkForm = ref<InstanceType<typeof CommonForm> | null>(null);
const handleOnSubmit = (_e: Event) => {
  // NOTE: CommonFormのformをsubmitする
  linkForm.value?.form?.submit();
};
</script>
<style lang="scss" scoped>
@use "@css/variables" as *;

.common-link {
  &__link-form {
    display: none;
  }
}
</style>

以下のような感じで利用することができます。

<CommonLink :href="/sessions" method="delete">ログアウト</CommonLink>

Ruby on Rails: Viewを作らずにControllerからVue.jsのSFCを指定してrenderできないか試したMEMO

はじめに

RailsでVue.jsを採用している場合、Vue SFCscoped cssを利用してCSS周りの統制と取りたくなりますが、RailsのView層に色々要素を書いてしまうとそれも難しくなってきます。

vue-loader-v14.vuejs.org

なので、RailsのControllerから直接Vue.jsのSFCを指定してrenderするような仕組みができないか色々試してみたのでメモしておく📝

基本方針

以下のようにrender_vue_componentの引数にコンポーネントpropsを渡すと<index-post-page />< show-post-page :post-id="id">みたいなVueコンポーネントの要素を差し込んだhtmlを返却するような仕組みを検討します。

class PostsController < ApplicationController
  def index
    render_vue_component('index-post-page')
  end

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

このようなケースだとinertiajs.jsを使えば良さそうにも思いましたが、単純にscoped cssCSSの統制を取りたいという要求に対して少し過剰な印象を持ったので、Railsの仕組みに乗りつつ直接Vue SFC指定してcontrollerからviewをrenderするような感覚で実現できないか検討してみる。

inertiajs.com

github.com

実装したコード

render_vue_componentでVue SFCを直接指定できるように以下のようなtemplateとconcernを実装してみました。

render_vue_componentコンポーネント名とpropsmount_element_idを渡すとmount_element_id(default: 'vue-root')をidとして指定したdivタグの中にhelpers.content_tag(@component, '', @props)で生成したタグ(ex. <show-post-page :post-id="id" >)をcomponent/template.erbに差し込んで返却します。

simpacker等を採用しておりjavascript_pack_tagでentryのjs、stylesheet_pack_tagでentryのcssを読み込むことが前提になってますがjavascript_entry_tagstylesheet_entry_tagをoverrideすれば任意のタグに差し替えることができます。

entryはデフォルトでcontroller名/action名(ex. PostsController#show -> posts/show)が指定されるようにしてみました。

<div id="<%= mount_element_id %>">
  <%= component_tag %>
</div>
<% content_for :stylesheet do %>
  <%= stylesheet_entry_tag %>
<% end %>
<% content_for :javascript do %>
  <%= javascript_entry_tag %>
<% end %>
# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, props: {}, mount_element_id: nil)
    @component = to_kebab_case(component)
    @props = props
    @entry = entry.presence || default_entry
    mount_element_id = mount_element_id.presence || default_mount_element_id
    render defalt_template_path,
           locals: { mount_element_id: mount_element_id, component: @component, entry: entry, props: @props }
  end

  def component_tag
    helpers.content_tag(@component, '', @props)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def default_mount_element_id
    'vue-root'
  end

  def defalt_template_path
    'component/template'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

(追記) 以下のようなrender inlineを使うとcomponent/temaplete.erb消せるのでよりシンプルに実現できそうだった。

# frozen_string_literal: true

module ComponentRenderable
  extend ActiveSupport::Concern

  included do
    helper_method :component_tag, :javascript_entry_tag, :stylesheet_entry_tag
  end

  def render_vue_component(component, entry: nil, mount_element_id: nil, props: {})
    @component = to_kebab_case(component)
    @options = props
    @entry = entry.presence || default_entry
    @mount_element_id = mount_element_id.presence || default_mount_element_id
    render inline: template, # rubocop:disable Rails/RenderInline
           locals: { mount_element_id: @mount_element_id, component: @component, entry: entry, options: @options },
           layout: true
  end

  def component_tag
    helpers.content_tag(@component, '', @options)
  end

  def javascript_entry_tag
    helpers.javascript_pack_tag(@entry, defer: true)
  end

  def stylesheet_entry_tag
    helpers.stylesheet_pack_tag(@entry)
  end

  private

  def template
    <<~HTML
      <div id="<%= mount_element_id %>">
        <%= component_tag %>
      </div>
      <% content_for :stylesheet do %>
        <%= stylesheet_entry_tag %>
      <% end %>
      <% content_for :javascript do %>
        <%= javascript_entry_tag %>
      <% end %>
    HTML
  end

  def default_mount_element_id
    'vue-root'
  end

  def default_entry
    File.join(controller_name, action_name)
  end

  def to_kebab_case(text)
    text.underscore.tr('_', '-')
  end
end

利用例

以下のようなentryを実装してある前提で、

import { createApp } from "vue";
import ShowPostPage from "@js/components/pages/ShowPostPage.vue";

const app = createApp(ShowPostPage);
app.mount("#vue-root");

以下のようにcontroller内でrender_vue_componentを利用することで、

class PostsController < ApplicationController
  include ComponentRenderable

  def show
    render_vue_component('show-post-page', props: { ':post-id': params[:id] })
  end
end

以下のようなhtmlが返却され、Vueインスタンスのmount及び指定したpropsを指定してComponentがレンダリングされます。

<div id="vue-root">
  <show-post-page :post-id="9"></show-post-page>
</div>

おわりに

とりあえず、そこまで多くのコードを書かずにViewを作らずControllerからVue.jsのSFCを指定してrenderするようなことは出来そうだなと思いましたが、多少なりとも独自の仕組みを作るとトレンドに合わせて独自の仕組みをメンテナンスするコストが発生するのが難しいところ。。。

CSSの統制とりたいというニーズにおいてはTailwind CSSをしたり、必ずしも独自の仕組みを用意する必要はないかもしれないのとView Componentのようなメジャーなライブラリでもscoped cssののような機能が議論されたりしてるので、こちらを期待するのも良いかもしれない。

github.com

Vue.js: CSRFTokenの設定やmethodを仕込むRailsの`form_with`っぽいComponentを作るメモ📝

Ruby on Railsで実装していたViewをVue.jsのComponentに置き換える場合に、form_withはよしなに行ってくれていたCSRF Tokenの設定やPATCH等のGETPOST以外のmethodをRailsに認識させるためにhiddenで送信したりする処理を独自に実装する必要があります。

form_with options

  • :method - The method to use when submitting the form, usually either “get” or “post”. If “patch”, “put”, “delete”, or another verb is used, a hidden input named _method is added to simulate the verb over post.
  • :authenticity_token - Authenticity token to use in the form.

https://api.rubyonrails.org/v7.0/classes/ActionView/Helpers/FormHelper.html#method-i-form_with

毎回formを作る度に実装するのは手間なのでいい感じに設定するComponentを作ってみたのでメモ📝

CSRFTokenの設定やmethodを仕込むRailsform_withっぽいComponentの実装

以下が実装してみたComponentです。propsで受け取ったrequestMethodGET以外だったらPOSTとして送信し、Railsに認識させるためにhiddenで元のrequestMethod(PATCH等)を送信するようにしているのと、 rails/ujscsrfToken相当の処理で取得したTokenをhiddenで仕込んでいます。

<template>
  <form ref="form" class="common-form" :action="requestPath" accept-charset="UTF-8" :method="formMethod">
    <input type="hidden" name="_method" :value="requestMethod" autocomplete="off" />
    <input type="hidden" name="authenticity_token" :value="authenticityToken" autocomplete="off" />
    <slot />
  </form>
</template>
<script setup lang="ts">
import { computed, ref } from "vue";
type Props = {
  requestMethod: "get" | "post" | "patch" | "put" | "delete";
  requestPath: string;
};
// NOTE: rails/ujs の csrfTokenの処理をコピペしている
// https://github.com/rails/rails/blob/ccb646244ff3768f796a5d9e0d22b833df3a6af6/actionview/app/assets/javascripts/rails-ujs.js#L47-L50
const csrfToken = () => {
  const meta = document.querySelector("meta[name=csrf-token]");
  return meta && (meta as HTMLMetaElement).content;
};
const props = defineProps<Props>();
const authenticityToken = csrfToken();
const formMethod = computed(() => (props.requestMethod === "get" ? "get" : "post"));
const form = ref<HTMLFormElement | null>(null);

// NOTE: 外部からもsubmitしたいケースを考慮して公開しとく
defineExpose({ form });
</script>
<style lang="scss" scoped></style>

実際に利用する際には以下のような感じで利用できます。

<template>
  <common-form request-path="/posts" request-method="post">
    <label for="title" >タイトル</label>
    <input type="text" name="title" />
    <button type="submit">登録する</button>
  </common-form>
</template>
<script lang="ts" setup>
import CommonForm from "@js/components/molecules/CommonForm.vue";
</script>

おわりに

自分で実装してみて改めて思いましたが@rails/ujs便利ですね🙏

`ruby-openai`を使ってRubyでChat GPTのAPIを呼び出すMEMO

Ruby on Rails製の個人のWebサービスにOpen APIのChat GPTを使った機能を盛り込んだ時にruby-openaiを使うと簡単にリクエス部分を実装できたので、ほとんど公式READMEの通りですがメモ📝

github.com

API KEYの取得方法等は以下の記事を参考にしてください。

madogiwa0124.hatenablog.com

使い方は簡単で以下の通りに実装すればいい感じにOpenAIのChat GPTのレスポンスを受け取ることができます。

client = OpenAI::Client.new(access_token: "access_token_goes_here")

response = client.chat(
    parameters: {
        model: "gpt-3.5-turbo", # Required.
        messages: [{ role: "user", content: "Hello!"}], # Required.
    })
puts response.dig("choices", 0, "message", "content")
# => Hello! How can I assist you today?

以下にサンプルとして適当な文字列の分かりやすい要約を生成するclassを作ってみました。

# frozen_string_literal: true

class SummaryText
  def initialize(text: nil)
    @original = text
  end

  def summary
    @summary ||= summarize!
  end

  attr_reader :original

  private

  def summarize!
    fetch_summary!
  end

  def fetch_summary!
    fetch_from_open_ai_chat(
      '渡された文字列を小学生でも理解できるように150文字で説明した文章を出力してください。',
      original
    )
  end

  def fetch_from_open_ai_chat(system_prompt, text)
    response = client.chat(
      parameters: {
        model: 'gpt-3.5-turbo',
        messages: [
          { role: 'system', content: system_prompt },
          { role: 'user', content: text }
        ]
      }
    )
    response.dig('choices', 0, 'message', 'content')
  end

  def client
    @client ||= OpenAI::Client.new(access_token: open_ai_token)
  end

  def open_ai_token
    Rails.configuration.settings.open_ai[:secret]
  end
end

以下のような感じで結果を取得できます🙌

SummaryText.new(text: "検索(けんさく、英: search)とは、データの集合の中から目的とするデータを探し出すことである。古くは図書館の所蔵物を探し出したり、辞書の項目を引いたりといった人手で行うのが主だったが、コンピューターの発達により、テキスト文字列の検索(文書検索、文字列探索)、画像データの検索(画像検索)、音声データの検索(音声検索)など、大規模かつマルチメディアの情報に関する検索技術が発展した。さらにデータベースの発展とインターネットの普及に伴い、分散保管されているデータに対する検索技術が研究されている。ファイルの内容に対して文字列探索を行う機能も検索と呼ばれる。").summary
=> "「検索」とは、目的のデータを見つけることです。昔は本を探したり辞書を引くなど、人が手で行っていました。しかし、コンピューターが発達し、文章や画像、音声など、大量の情報を探すことができるようになりました。また、データが分散保管されている場合でも検索できるようになっています。ファイルの中から特定の文字列を探す機能も「検索」と呼びます。"