データ登録サービスの構築 その1

データを登録するためのサービスが必要ということがわかったので、ここのところはClojureでできそうか技術検証をしていた。 検証は先日出した記事で終えたので、いよいよ実装していく。

サービス名はdata-registryにしよう。

data-registryが必要な背景

下の記事でもふり返ったが、このあたりで定義していたmonolithサービスのAPIは、パラメータが多すぎて使いにくかった。

frasyrのvpa()に渡す個々のパラメータをAPIに露出させるのではなく、登録済みデータセットのversionIdを渡すほうが 、データの監査的にも良さそうという見通し。

そこで、データ登録を受け付け、登録済みデータセットのversionIdを返すサービス: data-registryが欲しいわけ。

data-registryの使われ方は下図のような感じ:

BFFからのデータ登録を受付け、monolithからの問い合わせに応じる

BFFからのデータ登録を受付け、monolithからの問い合わせに応じる

仕様を考える

いろいろ考えてみたのだが、data-registryには下記のようなリクエストを投げたい:

% curl /v0/catch-at-age \
  --request POST \
  --header 'Content-Type: application/json' \
  --data '{
  "metadata": {
    "species": "マイワシ",
    "stock": "太平洋系群",
    "assessmentYear": 2026
  },
  "dataset": {
    "valueUnit": "ton",
    "dataSet": {
      "0": {
        "2025": 123,
        "2026": 456
      },
      "1": {
        "2025": 234,
        "2026": 567
      },
      "2": {
        "2025": 345,
        "2026": 678
      },
      "3": {
        "2025": 456,
        "2026": 789
      },
      "4": {
        "2025": 789,
        "2026": 890
      },
      "5+": {
        "2025": 890,
        "2026": 901
      }
    }
  }
}'

↑ポイントは、metadataを持っていることと、dataSetのキーに5+(5歳以上)があること1

期待するレスポンスは下記:

{"version":"019f8bff-a5b8-7014-bfc8-41561eaac24c"}

↑もちろん、これが返るまでにデータベースへのデータ登録がある。

実装手順

data-registryの機能を実現するエッセンスは別記事で既に検証済みなので、さっそく実装手順を考えてみる。

  1. API契約を書く
  2. ルーティングの動作確認
  3. db セットアップ
  4. リポジトリ・ハンドラの実装
  5. datasetVersion生成ロジックの設計
  6. リファクタリング
  7. エラーハンドリングの追加

こんなところか。

やってみる

API契約を書く

年齢別漁獲量データを登録するためのエンドポイントを作ってみる。 OpenAPI形式で下記のように書いた:

---
openapi: 3.2.0
info:
  title: Data Registry API
  description: |
    Immutable data service for stock assessment workflow.

    Registers and versions biological datasets and parameter sets.
    Returns version identifiers that other services reference for reproducibility and immutability.
  version: 0.1.0
  contact:
    name: dev+yoshimoto-samonji@rindrics.com
  license:
    name: MIT

      responses:
        200:
          content:
            application/json:
              schema:
                summary: Return version of catch-at-age dataset
                type: object
                properties:
                  version:
                    type: string
              example:
                version: 019f8bff-a5b8-7014-bfc8-41561eaac24c

paths:
  /v0/catch-at-age:
    post:
      operationId: catch-at-age-POST
      summary: Register catch-at-age dataset
      description: Register a catch-at-age dataset with metadata
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - metadata
                - dataset
              properties:
                metadata:
                  type: object
                  required:
                    - species
                    - stock
                    - assessmentYear
                  properties:
                    species:
                      type: string
                      example: "マイワシ"
                      description: Name of target species
                    stock:
                      type: string
                      example: "太平洋系群"
                      description: Stock or population identifier
                    assessmentYear:
                      type: integer
                      example: 2026
                      description: Year of stock assessment
                    notes:
                      type: string
                      description: Additional notes or comments
                dataset:
                  type: object
                  required:
                    - valueUnit
                    - dataSet
                  properties:
                    valueUnit:
                      type: string
                      enum: ["kg", "ton"]
                      description: Unit of catch weight
                    dataSet:
                      type: object
                      description: "Mapping of age_class:{year:catch_weight}"
                      additionalProperties:
                        type: object
                        description: "Mapping of year:catch_weight"
                        additionalProperties:
                          description: Catch weight
                          type: number
                          minimum: 0
      responses:
        200:
          content:
            application/json:
              schema:
                summary: Return version of catch-at-age dataset
                type: object
                properties:
                  version:
                    type: string
              example:
                version: 019f8bff-a5b8-7014-bfc8-41561eaac24c

↑レスポンスは、まずは正常系だけ書いたところ。

ルーティングの動作確認

APIスキーマを変えたので、前回の記事で書いたschema.cljを微修正する:

   (eval `(def ~sym-name ~schema))))

- (defn validate-favorite-fish-request
-  "Validate favorite-fish request, throw if invalid"
+ (defn validate-catch-at-age-request
+   "Validate catch-at-age request, throw if invalid"
    [data]
-   (let [schema (get schemas :favorite-fish-request)]
+   (let [schema (get schemas :catch-at-age-request)]
      (if-let [error (m/explain schema data)]
-       (throw (ex-info "Invalid favorite-fish request" error))
+       (throw (ex-info "Invalid catch-at-age request" error))
      data)))

↑エンドポイント名を変えただけだが。

続いて、ルーターのコードも対応して修正する:

   (eval `(def ~sym-name ~schema))))
- (defn favorite-fish-handler
-   "POST /v0/favorite-fish"
+ (defn catch-at-age-handler
+   "POST /v0/catch-at-age"
    [req]
    (try
      (log/info "Request body:" (:body req))
      (let [body (:body req)
-           validated-body (schema/validate-favorite-fish-request body)
-           n    (get-in validated-body [:fish :name])
-           sn   (get-in validated-body [:fish :scientific_name])
-           response {:message (format "Your favorite fish is %s (%s), right?" n sn)} 
-           validated-response (schema/validate-response :favorite-fish-200 response)]
+           validated-body (schema/validate-catch-at-age-request body)
+           species (get-in validated-body [:metadata :species])
+           stock (get-in validated-body [:metadata :stock])
+           year (get-in validated-body [:metadata :assessmentYear])
+           response {:message (format "Registered %s (%s) for year %d" species stock year)}
+           validated-response (schema/validate-response :catch-at-age-200 response)]
          {:status 200 :body validated-response})
      (catch Exception e
-       (log/error "Error in favorite-fish-handler" {:error (.getMessage e)})
+       (log/error "Error in catch-at-age-handler" {:error (.getMessage e)})
        {:status 500 :body {:error (.getMessage e)}})))
  
  (defn wrap-json-with-keywords [handler]
    (wrap-json-body handler {:key-fn keyword, :charset "utf-8"}))
  
  (def routes
-   [["/v0/favorite-fish"
+   [["/v0/catch-at-age"
      {:post {:summary "Register favorite fish"
-             :parameters {:body schema/favorite-fish-request}
-             :responses {200 {:body schema/favorite-fish-200}}
-             :handler favorite-fish-handler}}]])
+             :parameters {:body schema/catch-at-age-request}
+             :responses {200 {:body schema/catch-at-age-200}}
+             :handler catch-at-age-handler}}]])

ここから、Malliスキーマの生成やルーティングの動作確認をしたいので、こちらの記事で使っていたdocker-compose.ymlを再利用しつつ、下記のようなMakefileを用意した:

.PHONY: run generate

LOG_LEVEL ?= DEBUG
COMPOSE_PROJECT_NAME ?= yoshimoto-samonji
DOCKER_NETWORK := $(COMPOSE_PROJECT_NAME)_yoshimoto-samonji

generate:
	mkdir -p resources
	clj scripts/generate-from-openapi.clj

run:
	DATA_REGISTRY_DEV_PORT=$(DATA_REGISTRY_DEV_PORT) \
	LOG_LEVEL=$(LOG_LEVEL) \
	docker compose -f ../../docker-compose.yml -p $(COMPOSE_PROJECT_NAME) run --rm -p $(DATA_REGISTRY_DEV_PORT):$(DATA_REGISTRY_DEV_PORT) repl clj -M -m data-registry.core

さっそくOpenAPIからMalliスキーマを生成してみる:

% make generate
mkdir -p resources
clj scripts/generate-from-openapi.clj
WARNING: Implicit use of clojure.main with options is deprecated, use -M scripts/generate-from-openapi.clj
Found request schema: :catch-at-age-request
Found response schema: :catch-at-age-200
Generated resources/schemas.edn
{:catch-at-age-request [:map [:metadata [:map [:species :string] [:stock :string] [:assessment_year :int] [:notes :string]]] [:dataset [:map [:valueUnit :string] [:dataSet [:map]]]]], :catch-at-age-200 [:map [:detail :string]]}

↑一応できたように見える。

動作確認のためにAPIサーバを起動する:

% make run
DATA_REGISTRY_DEV_PORT=31505 \
        LOG_LEVEL=DEBUG \
        docker compose -f ../../docker-compose.yml -p yoshimoto-samonji run --rm -p 31505:31505 repl clj -M -m data-registry.core
WARN[0000] No services to build 
[+]  1/1t 1/11
 ✔ Container yoshimoto-samonji-db-1 Running       0.0s 
WARN[0000] No services to build 
Container yoshimoto-samonji-repl-run-a5b67937303c Creating 
Container yoshimoto-samonji-repl-run-a5b67937303c Created 



2026-07-23 05:04:32.338:INFO::main: Logging initialized @873ms to org.eclipse.jetty.util.log.StdErrLog
2026-07-23 05:04:34.225:INFO:oejs.Server:main: jetty-9.4.51.v20230217; built: 2023-02-17T08:19:37.309Z; git: b45c405e4544384de066f814ed42ae3dceacdd49; jvm 25.0.3+9-LTS
2026-07-23 05:04:34.248:INFO:oejs.AbstractConnector:main: Started ServerConnector@79ac50fe{HTTP/1.1, (http/1.1)}{0.0.0.0:31505}

↑リッスンできた。

リクエストを送ってみる:

% curl -X POST localhost:31505/v0/catch-at-age \
  -d '{"metadata": {
         "species": "マイワシ",
         "stock": "太平洋系群",
         "assessmentYear": 2026},
       "dataset": {
         "valueUnit": "ton",
         "dataSet": {
           "0": {"2025": 123, "2026": 456},
           "1": {"2025": 234, "2026": 567}
          }
        }
      }' \
  -H "Content-Type: application/json" && echo
{"error":"Invalid catch-at-age request"}
% 

↑おや。

API schemaに足したrequiredフィールドをパースできていなかった:

  #!/usr/bin/env clj
  
  (require '[clj-yaml.core :as yaml])
  
  (defn json-schema->malli
    "Convert JSON Schema into Malli schema"
    [schema]
    (let [type (get schema :type)]
      (cond
        (= type "object")
        (let [properties (get schema :properties {})      
+             required (set (get schema :required []))
              fields (map (fn [[prop-name prop-schema]]
-                           [(keyword prop-name) (json-schema->malli prop-schema)])
+                           (let [kw (keyword prop-name)
+                                 is-required (contains? required (name kw))]
+                             (if is-required
+                               [kw (json-schema->malli prop-schema)]
+                               [kw {:optional true} (json-schema->malli prop-schema)])))
                          properties)]                          
                          properties)]
          (into [:map] fields))
  
        (= type "string") :string
        (= type "integer") :int
        (= type "number") :number
        (= type "boolean") :boolean
        (= type "array")
        (let [items (get schema :items)]
          (if items
            [:vector (json-schema->malli items)]
            :vector))
  
        :else :any)))
  
  (defn extract-schemas [openapi]
    (let [paths (get openapi :paths)
          schemas (atom {})]
      (doseq [[path path-item] paths]
        (doseq [[_method operation] path-item]
          (when (map? operation)
            ; request schema
            (when-let [req-body (get operation :requestBody)]
              (when-let [json-schema (get-in req-body [:content :application/json :schema])]
                (let [schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-request"))]
                  (println "Found request schema:" schema-name)
                  (swap! schemas assoc schema-name (json-schema->malli json-schema)))))
  
            ; response schema
            (when-let [responses (get operation :responses)]
              (doseq [[status response] responses]
                (when-let [json-schema (get-in response [:content :application/json :schema])]
                  (let [status-str (or status "200")
                        schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-" status-str))]
                    (println "Found response schema:" schema-name)
                    (swap! schemas assoc schema-name (json-schema->malli json-schema)))))))))
      @schemas))
  
  (defn -main [& _args]
    (let [openapi (yaml/parse-string (slurp "schema/openapi.yaml"))
          schemas (extract-schemas openapi)
          edn-output (with-out-str (pr schemas))]
      (spit "resources/schemas.edn" edn-output)
      (println "Generated resources/schemas.edn")
      (println schemas)))
  
  (-main)

requiredでないフィールドには{:optional: true}が設定されるようにした。

もういちどmake generateしてからサーバーを起動したら、今度は成功した:

% curl -X POST localhost:31505/v0/catch-at-age \
  -d '{"metadata": {
         "species": "マイワシ",
         "stock": "太平洋系群",
         "assessmentYear": 2026},
       "dataset": {
         "valueUnit": "ton",
         "dataSet": {
           "0": {"2025": 123, "2026": 456},
           "1": {"2025": 234, "2026": 567}
          }
        }
      }' \
  -H "Content-Type: application/json" && echo
{"message":"Registered マイワシ (太平洋系群) for year 2026"}

バリデーションが利いているかを確認したいので、invalidなリクエストを投げてみる:

% curl -XPOST localhost:31505/v0/catch-at-age \
-d '{"fish": {
       "name": "マイワシ",
       "scientific_name": "Sardinops melanostictus"
      }
    }' \
-H "Content-Type: application/json" && echo

{"error":"Invalid catch-at-age request"}

↑想定通り、バリデーション失敗を確認できたのでOK!

データベースのセットアップ

次はデータをデータベースに登録できるようにする。 前掲のdocker-compose.ymlこちらのMakefileを使ってmake docker-upする:

% make docker-up
MONOLITH_IMAGE=yoshimotosamonji-monolith:latest \
        DATA_REGISTRY_IMAGE=yoshimotosamonji-data-registry:latest \
        VALIDATE_RESPONSE= \
        docker compose up -d --no-build monolith data-registry db repl
[+] up 4/4
 ✔ Container yoshimoto-samonji-db-1            Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-monolith-1      Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-repl-1          Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-data-registry-1 Recreated 

↑データベースコンテナが立った(検証しながらやってるので他のも立ってる)。

マイグレーション

データベース操作の検証では手動でセットアップをしたが、今後の開発効率を考えてマイグレーションを仕組み化しておきたい。 マイグレーションツールにはコンテナ版のFlywayを使うことにして、下記のようなマイグレーションスクリプトを作った:

CREATE TABLE IF NOT EXISTS data_versions (
  id SERIAL PRIMARY KEY,
  version_id VARCHAR(64) UNIQUE NOT NULL,   -- SHA-256 content hash
  data_type VARCHAR(50) NOT NULL,           -- catch_at_age, weight_at_age, maturity_at_age, parameter_set
  metadata JSONB NOT NULL,                  -- {species, stock, assessment_year, ...}
  data JSONB NOT NULL,                      -- The actual normalized data
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

-- Indexes for common queries
CREATE INDEX idx_data_versions_version_id ON data_versions(version_id);
CREATE INDEX idx_data_versions_data_type ON data_versions(data_type);
CREATE INDEX idx_data_versions_created_at ON data_versions(created_at);

↑プライマリキーにはレスポンスのversionIdに使うversion_idではなく、自動インクリメントのidを使うことにした。

マイグレーション用のmakeターゲットを追加する:

+ migrate-data-registry:
+ 	docker run --rm \
+ 	  --network $(DOCKER_NETWORK) \
+ 	  -v $$(pwd)/services/data-registry/resources/migrations:/flyway/sql \
+ 	  flyway/flyway:latest \
+ 	  -url=jdbc:postgresql://$(POSTGRES_HOST):$(POSTGRES_PORT)/$(POSTGRES_DB) \
+ 	  -user=$(POSTGRES_USER) \
+ 	  -password=$(POSTGRES_PASSWORD) \
+ 	  -baselineOnMigrate=true \
+ 	  migrate
+ 
+ migrate: migrate-data-registry

マイグレーションしてみる:

% make migrate       
docker run --rm \
          --network yoshimoto-samonji_yoshimoto-samonji \
          -v $(pwd)/services/data-registry/resources/migrations:/flyway/sql \
          flyway/flyway:latest \
          -url=jdbc:postgresql://db:5432/yoshimoto_samonji \
          -user=yoshimoto \
          -password=samonji \
          -baselineOnMigrate=true \
          migrate
WARNING: Storing migrations in 'sql' is not recommended and default scanning of this location may be deprecated in a future release
Flyway OSS Edition 13.0.0 by Redgate

See release notes here: https://help.red-gate.com/help/flyway-cli13/help_0.aspx?topic=release-notes-and-older-versions/release-notes-for-flyway-engine
Database: ******** (PostgreSQL 18.4)
Schema history table "public"."flyway_schema_history" does not exist yet
Successfully validated 1 migration (execution time 00:00.013s)
All configured schemas are empty; a baseline marker will not be added to Flyway's schema history table. A baseline or migration script with a lower version than the baseline version may execute if available. Check the Schemas parameter if this is not intended. See https://help.red-gate.com/help/flyway-cli13/help_0.aspx?topic=baseline-on-migrate for more info
Creating Schema History table "public"."flyway_schema_history" ...
Current version of schema "public": << Empty Schema >>
Migrating schema "public" to version "001 - init"
Successfully applied 1 migration to schema "public", now at version v001 (execution time 00:00.012s)
%

↑マイグレーションできた。

DBの状況を確認したいのでデータベースビューアを導入する。 コンテナ版adminerを使うことにした:

+   db-viewer:
+     image: adminer:latest
+     ports:
+       - "${DB_VIEWER_PORT:-8080}:8080"
+     depends_on:
+       - db
+     networks:
+       - yoshimoto-samonji
+     environment:
+       ADMINER_DEFAULT_SERVER: db

makeターゲットにもdb-viewerを追加しておく:

  	MONOLITH_IMAGE=$(MONOLITH_LOCAL_IMAGE):latest \
  	DATA_REGISTRY_IMAGE=$(DATA_REGISTRY_LOCAL_IMAGE):latest \
  	VALIDATE_RESPONSE=$(VALIDATE_RESPONSE) \
- 	$(DOCKER_COMPOSE) up -d --no-build monolith data-registry db repl
+ 	$(DOCKER_COMPOSE) up -d --no-build monolith data-registry db repl db-viewer

再度make docker-upする:

% make docker-up                                   
MONOLITH_IMAGE=yoshimotosamonji-monolith:latest \
        DATA_REGISTRY_IMAGE=yoshimotosamonji-data-registry:latest \
        VALIDATE_RESPONSE= \
        docker compose up -d --no-build monolith data-registry db repl db-viewer
[+] up 5/5
 ✔ Container yoshimoto-samonji-db-1            Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-monolith-1      Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-data-registry-1 Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-repl-1          Running                                                                                                                     0.0s 
 ✔ Container yoshimoto-samonji-db-viewer-1     Created    

db-viewerが起動した。

adminerの画面を見てみる:

マイグレーションによってテーブルdata_versionsができている

マイグレーションによってテーブルdata_versionsができている

↑マイグレーションによって意図通りの状態になっていた。

リポジトリ・ハンドラの実装

リポジトリを下記のように実装する:

(ns data-registry.repository
  (:require [next.jdbc :as jdbc]
            [next.jdbc.connection :as connection]
            [cheshire.core :as json]
            [clojure.tools.logging :as log])
  (:import [com.zaxxer.hikari HikariDataSource]))

(def ^:private pool (atom nil))

(defn init-pool []
  (if-let [db-url (System/getenv "DATABASE_URL")]
    (reset! pool
      (connection/->pool HikariDataSource
        {:jdbcUrl db-url
         :maximumPoolSize 5
         :connectionTimeout 30000
         :maxLifetime 600000}))
    (log/warn "DATABASE_URL not set, skipping pool initialization")))

(defn get-pool []
  (or @pool
      (do (log/warn "Pool not initialized, initializing now")
          (init-pool)
          @pool)))

(.addShutdownHook (Runtime/getRuntime)
  (Thread. ^{:name "db-pool-shutdown"}
    #(when-let [p @pool]
       (log/info "Closing database pool")
       (.close p))))

(defn insert-catch-at-age
  "Insert catch-at-age dataset into database"
  [version-id metadata dataset]
  (try
    (let [pool (get-pool)
          result (jdbc/execute! pool
                   ["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?, ?)"
                    version-id
                    "catch_at_age"
                    metadata
                    dataset])]
      (log/info "Inserted catch-at-age data with version_id:" version-id)
      result)
    (catch Exception e
      (log/error "Failed to insert catch-at-age data:" (.getMessage e))
      (throw e))))

必要な依存も下記のように追加する:

+   com.github.seancorfield/next.jdbc
+   {:mvn/version "1.3.894"}
+ 
+   org.postgresql/postgresql
+   {:mvn/version "42.7.3"}
+   cheshire/cheshire
+   {:mvn/version "5.13.0"}
+ 
+   com.zaxxer/HikariCP
+   {:mvn/version "5.1.0"}
+ 
+   ch.qos.logback/logback-classic
+   {:mvn/version "1.5.3"}}
  }

coreは下記のようにコネクションプールを確保するようにする:

  (ns data-registry.core
    (:require [ring.adapter.jetty :as jetty]
-             [data-registry.routes :as routes]))
+             [data-registry.routes :as routes]
+             [data-registry.repository :as repository]))

  (defn start-server []
+.  (repository/init-pool)
    (let [port (Integer/parseInt (or (System/getenv "CONTAINER_PORT") "3000"))]
      (jetty/run-jetty routes/app-router {:port port})))
  
  (defn -main [& args]
    (start-server))

ルータは下記のように変更する:

  (defn catch-at-age-handler
    "POST /v0/catch-at-age"
    [req]
    (try
      (log/info "Request body:" (:body req))
      (let [body (:body req)
            validated-body (schema/validate-catch-at-age-request body)
-           species (get-in validated-body [:metadata :species])
-           stock (get-in validated-body [:metadata :stock])
-           year (get-in validated-body [:metadata :assessmentYear])
-           response {:message (format "Registered %s (%s) for year %d" species stock year)}
+           metadata (:metadata validated-body)
+           dataset (:dataset validated-body)
+           version-id (str (System/currentTimeMillis))
+           _ (repository/insert-catch-at-age version-id metadata dataset)
+           response {:detail (format "Registered %s (%s)"
+                                      (:species metadata)
+                                      (:stock metadata))}
            validated-response (schema/validate-response :catch-at-age-200 response)]
        {:status 200 :body validated-response})
      (catch Exception e
        (log/error "Error in catch-at-age-handler" {:error (.getMessage e)})
        {:status 500 :body {:error (.getMessage e)}})))

↑ひとまず現時点では、リポジトリを直接使うことにする。

これまで通り、make upでサーバーを立ち上げ、リクエストを投げる:

% curl -X POST localhost:31505/v0/catch-at-age \
  -d '{"metadata": {
         "species": "マイワシ",
         "stock": "太平洋系群",
         "assessmentYear": 2026},
       "dataset": {
         "valueUnit": "ton",
         "dataSet": {
           "0": {"2025":123, "2026": 456},
           "1": {"2025":234, "2026": 567}
          }
        }
      }' \
  -H "Content-Type: application/json" && echo
{"error":"ERROR: column \"metadata\" is of type jsonb but expression is oftype hstore\n  Hint: Youwill need to rewrite or cast the expression.\n  Position: 83"}

↑おや。

JSONにシリアライズするのを忘れていた:

  (defn insert-catch-at-age
    "Insert catch-at-age dataset into database"
    [version-id metadata dataset]
    (try
      (let [pool (get-pool)
+           metadata-json (json/generate-string metadata)
+           dataset-json (json/generate-string dataset)    
            result (jdbc/execute! pool
-                    ["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?, ?)"
+                    ["INSERT INTO data_versions (version_id, data_type, metadata, data) VALUES (?, ?, ?::jsonb, ?::jsonb)"
                      version-id
                      "catch_at_age"
-                     metadata
-                     dataset])]
+                     metadata-json
+                     dataset-json])]
        (log/info "Inserted catch-at-age data with version_id:" version-id)
        result)
      (catch Exception e
        (log/error "Failed to insert catch-at-age data:" (.getMessage e))
        (throw e))))

再度リクエスト:

% curl -X POST localhost:31505/v0/catch-at-age \
  -d '{"metadata": {
         "species": "マイワシ",
         "stock": "太平洋系群",
         "assessmentYear": 2026},
       "dataset": {
         "valueUnit": "ton",
         "dataSet": {
           "0": {"2025":123, "2026": 456},
           "1": {"2025":234, "2026": 567}
          }
        }
      }' \
  -H "Content-Type: application/json" && echo
{"detail":"Registered マイワシ (太平洋系群)"}

↑今度は保存できた。

何度か叩いてみてからデータベースの状態を見てみると、無事データが登録されていることを確認できた:

JSONB型はデータを目視できる点も楽

JSONB型はデータを目視できる点も楽

ここまでで一段落だが、現時点の実装ではversionIdがただの現在時刻になってしまっている。 versionIdは結果に追跡性をもたせるために重要な値なので、次はここを直していく。

ちょっと力尽き気味なので今回はここまで。

本記事でやったことと残作業↓:

  • API契約を書く
  • ルーティングの動作確認
  • db セットアップ
  • リポジトリ・ハンドラの実装
  • datasetVersion生成ロジックの設計
  • リファクタリング
  • エラーハンドリングの追加

所感

  • 一人で手探りでやってると観点漏れ(セキュリティ、リソース管理、データキャパシティ設計など)が起こりがち
  • このプロジェクトを始めてから、OSSコミュニティへの感謝の念がさらに強まっている。ありがとうございます。

  1. 高齢魚は個体数が少ない傾向にあるので、資源評価ではある年齢以上をひとくくりの階級にすることが多い。「5+」は「5歳以上」の意味 ↩︎