diff --git a/Gemfile b/Gemfile index 8b9174e..8ddb135 100644 --- a/Gemfile +++ b/Gemfile @@ -18,6 +18,9 @@ gem "turbo-rails" gem "stimulus-rails" # Build JSON APIs with ease [https://github.com/rails/jbuilder] gem "jbuilder" +gem "grape" +gem "grape-swagger" +gem "rswag-ui" gem "caxlsx" gem "caxlsx_rails" diff --git a/Gemfile.lock b/Gemfile.lock index f218697..0f036ed 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -120,6 +120,26 @@ GEM docile (1.4.1) dotenv (3.2.0) drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-types (1.9.0) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) ed25519 (1.4.0) erb (6.0.1) erubi (1.13.1) @@ -131,6 +151,15 @@ GEM raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) + grape (3.1.1) + activesupport (>= 7.1) + dry-configurable + dry-types (>= 1.1) + mustermann-grape (~> 1.1.0) + rack (>= 2) + zeitwerk + grape-swagger (2.1.3) + grape (>= 1.7, < 4.0) htmlentities (4.4.2) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -181,6 +210,10 @@ GEM minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) + mustermann (3.0.4) + ruby2_keywords (~> 0.0.1) + mustermann-grape (1.1.0) + mustermann (>= 1.0.0) net-imap (0.6.2) date net-protocol @@ -273,6 +306,9 @@ GEM logger (~> 1) nokogiri (~> 1) rubyzip (>= 3.0.0, < 4.0.0) + rswag-ui (2.17.0) + actionpack (>= 5.2, < 8.2) + railties (>= 5.2, < 8.2) rubocop (1.84.0) json (~> 2.3) language_server-protocol (~> 3.17.0.2) @@ -305,6 +341,7 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger + ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.40.0) @@ -387,6 +424,8 @@ DEPENDENCIES caxlsx caxlsx_rails debug + grape + grape-swagger image_processing (~> 1.2) importmap-rails jbuilder @@ -395,6 +434,7 @@ DEPENDENCIES puma (>= 5.0) rails (~> 8.1.2) roo (~> 3.0) + rswag-ui rubocop-rails-omakase selenium-webdriver simplecov @@ -445,6 +485,11 @@ CHECKSUMS docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 + dry-configurable (1.3.0) sha256=882d862858567fc1210d2549d4c090f34370fc1bb7c5c1933de3fe792e18afa8 + dry-core (1.2.0) sha256=0cc5a7da88df397f153947eeeae42e876e999c1e30900f3c536fb173854e96a1 + dry-inflector (1.3.1) sha256=7fb0c2bb04f67638f25c52e7ba39ab435d922a3a5c3cd196120f63accb682dcc + dry-logic (1.6.0) sha256=da6fedbc0f90fc41f9b0cc7e6f05f5d529d1efaef6c8dcc8e0733f685745cea2 + dry-types (1.9.0) sha256=7b656fe0a78d2432500ae1f29fefd6762f5a032ca7000e4f36bc111453d45d4d ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 @@ -452,6 +497,8 @@ CHECKSUMS ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 + grape (3.1.1) sha256=774f16782d917a90e69de0499dfaab571e5ad967569ac066a2b0b918af12de69 + grape-swagger (2.1.3) sha256=9ee955ada77c10ea2f2d2da5edcc4fc3cddcc40a6936f1b2558ee7b44e0f1153 htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb @@ -472,6 +519,8 @@ CHECKSUMS mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 + mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22 + mustermann-grape (1.1.0) sha256=8d258a986004c8f01ce4c023c0b037c168a9ed889cf5778068ad54398fa458c5 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 @@ -508,6 +557,7 @@ CHECKSUMS reline (0.6.3) sha256=1198b04973565b36ec0f11542ab3f5cfeeec34823f4e54cebde90968092b1835 rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 roo (3.0.0) sha256=6fdd7a9158d657c69768b4168754ff2110cc21fdc01a1bec1010820cb05c91b1 + rswag-ui (2.17.0) sha256=5f707b9b5e8171ddf9f519f6e401e79e419bd1d07387508603e76124f2443212 rubocop (1.84.0) sha256=88dec310153bb685a879f5a7cdb601f6287b8f0ee675d9dc63a17c7204c4190a rubocop-ast (1.49.0) sha256=49c3676d3123a0923d333e20c6c2dbaaae2d2287b475273fddee0c61da9f71fd rubocop-performance (1.26.1) sha256=cd19b936ff196df85829d264b522fd4f98b6c89ad271fa52744a8c11b8f71834 @@ -515,6 +565,7 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 + ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b diff --git a/README.md b/README.md index 094433f..bde9094 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ## Overview -"Sanasto Wiki" is a web-based dictionary application for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors. +"Sanasto Wiki" is a web-based glossary for simultaneous translators in the living Christianity. The application provides publicly accessible translations while restricting editing and commenting to invited contributors. --- @@ -188,15 +188,25 @@ See 'public/Kristillisyyden sanasto ver 23.5.2013.xlsx' --- -## API (Optional Future) +## API (Public Sync) -REST API for potential mobile app or integration: +Public JSON endpoint for syncing entries: ``` -GET /api/entries -GET /api/entries/:id -GET /api/entries/search?q=:query&lang=:code -POST /api/entries (authenticated) -PATCH /api/entries/:id (authenticated) +GET /api/entries +GET /api/entries?since=2026-01-01T12:00:00Z +``` + +Responses include all language columns, category and `updated_at`. The optional `since` +parameter filters by `updated_at` (ISO8601). + +Swagger docs: +``` +GET /api/swagger +``` + +Swagger UI: +``` +GET /api ``` ## Deployment diff --git a/app/controllers/api/base.rb b/app/controllers/api/base.rb new file mode 100644 index 0000000..bfee9a6 --- /dev/null +++ b/app/controllers/api/base.rb @@ -0,0 +1,58 @@ +require "grape" +require "grape-swagger" +require "ostruct" + +class Api::Base < Grape::API + format :json + content_type :json, "application/json" + + helpers do + def parse_since_param(raw_since) + return nil if raw_since.blank? + + Time.iso8601(raw_since) + rescue ArgumentError + error!({ error: "Invalid since parameter. Use ISO8601 timestamp." }, 400) + end + end + + resource :entries do + desc "Return public entries in all languages", + attributes: OpenStruct.new(success: nil, produces: nil) + params do + optional :since, + type: String, + desc: "ISO8601 timestamp. Returns entries updated after this time." + end + get do + since_time = parse_since_param(params[:since]) + + entries_scope = Entry.active_entries + entries_scope = entries_scope.where("updated_at > ?", since_time) if since_time + + entries_scope + .order(:updated_at, :id) + .select( + :id, + :category, + :fi, + :en, + :sv, + :no, + :ru, + :de, + :updated_at + ) + end + end + + add_swagger_documentation( + info: { + title: "Sanasto Wiki API", + description: "Public sync API for Sanasto Wiki glossary entries." + }, + mount_path: "/swagger", + hide_documentation_path: true, + format: :json + ) +end diff --git a/config/initializers/rswag_ui.rb b/config/initializers/rswag_ui.rb new file mode 100644 index 0000000..75937ec --- /dev/null +++ b/config/initializers/rswag_ui.rb @@ -0,0 +1,3 @@ +Rswag::Ui.configure do |config| + config.swagger_endpoint "/api/swagger", "Sanasto Wiki API" +end diff --git a/config/routes.rb b/config/routes.rb index e6ea5de..b6a2f85 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -51,6 +51,9 @@ Rails.application.routes.draw do end end + mount Api::Base => "/api" + mount Rswag::Ui::Engine => "/api" + resources :entries do resources :comments, only: [ :create ] collection do diff --git a/test/integration/api_entries_test.rb b/test/integration/api_entries_test.rb new file mode 100644 index 0000000..406f388 --- /dev/null +++ b/test/integration/api_entries_test.rb @@ -0,0 +1,63 @@ +require "test_helper" + +class ApiEntriesTest < ActionDispatch::IntegrationTest + test "returns active entries with language fields" do + entry = entries(:one) + + get "/api/entries" + + assert_response :success + payload = JSON.parse(response.body) + entry_payload = payload.find { |item| item["id"] == entry.id } + + assert_not_nil entry_payload + assert_equal entry.fi, entry_payload["fi"] + assert_equal entry.en, entry_payload["en"] + assert_equal entry.sv, entry_payload["sv"] + assert_equal entry.no, entry_payload["no"] + assert_equal entry.ru, entry_payload["ru"] + assert_equal entry.de, entry_payload["de"] + end + + test "filters by updated_at when since param is provided" do + older_entry = Entry.create!( + fi: "Older Entry", + category: :word, + status: :active + ) + older_entry.update_column(:updated_at, 2.days.ago) + + newer_entry = Entry.create!( + fi: "Newer Entry", + category: :word, + status: :active + ) + newer_entry.update_column(:updated_at, 1.hour.ago) + + get "/api/entries", params: { since: 1.day.ago.iso8601 } + + assert_response :success + payload = JSON.parse(response.body) + returned_ids = payload.map { |item| item["id"] } + + assert_includes returned_ids, newer_entry.id + assert_not_includes returned_ids, older_entry.id + end + + test "returns bad request for invalid since param" do + get "/api/entries", params: { since: "not-a-time" } + + assert_response :bad_request + payload = JSON.parse(response.body) + assert_equal "Invalid since parameter. Use ISO8601 timestamp.", payload["error"] + end + + test "exposes swagger docs" do + get "/api/swagger" + + assert_response :success + payload = JSON.parse(response.body) + assert_equal "Sanasto Wiki API", payload.dig("info", "title") + assert_includes payload.keys, "paths" + end +end