Compare commits

...

54 Commits

Author SHA1 Message Date
google-labs-jules[bot]
111bdb2062 feat: Improve Swagger documentation
This commit improves the Swagger documentation by using rswag to generate it from the request specs.

The following changes were made:
- All request specs in `spec/requests/api/v1/` were updated to use the rswag DSL.
- The `spec/swagger_helper.rb` was configured to generate a `swagger.json` file.
- The `config/database.yml` was updated to use environment variables, which makes it easier to use in different environments.
- The generated `swagger.json` file is now based on the OpenAPI 3.0 specification.
2025-09-10 12:36:32 +00:00
Daniel O'Connor
99478e3920 Rubocop (#4242) 2025-09-10 20:46:12 +09:30
Daniel O'Connor
a2f05097af Merge branch 'mainline' into dev 2025-09-10 20:02:31 +09:30
Daniel O'Connor
e5bf9d98e6 Rubocop (#4241) 2025-09-10 19:56:12 +09:30
Daniel O'Connor
7988080054 Update .rubocop.yml 2025-09-10 19:52:44 +09:30
google-labs-jules[bot]
02db5b8130 Add API token generation, authentication, and CRUD for a number of the API resources (#4237)
* feat: Add API token generation and authentication

This commit introduces API token generation and authentication for write operations.

- Adds a section to the user's profile edit page to generate and display an API token.
- Reuses the `authentications` table to store the API token, avoiding the need for a database migration.
- Implements token-based authentication for the API using the `Authorization: Token token=...` header.
- Enables write operations for all API resources and ensures they are protected by the new authentication mechanism.
- Adds feature and request specs to test the new functionality.

* feat: Add API token generation and authentication

This commit introduces API token generation and authentication for write operations.

- Adds a section to the user's profile edit page to generate and display an API token.
- Reuses the `authentications` table to store the API token, avoiding the need for a database migration.
- Implements token-based authentication for the API using the `Authorization: Token token=...` header.
- Enables write operations for all API resources and ensures they are protected by the new authentication mechanism.
- Adds feature and request specs to test the new functionality.

* Mark as editable

* Refactor

* WIP - Authentication

* Implement more test coverage

* Split 401 and 403

* Before Create hooks

* Update harvest specs, defaulting to the first plant part - this may not be right

* Update coverage

* Update coverage

* Rubocop

* Rubocop

* Rubocop

* Fix coverage

* For now, mark photos immutable again

* Fix specs

* Fix specs

* Rubocop

* Fix specs

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Daniel O'Connor <daniel.oconnor@gmail.com>
2025-09-10 19:50:06 +09:30
Daniel O'Connor
cf8380029a Rubocop 2025-09-10 10:19:08 +00:00
Daniel O'Connor
eefda21d1a Merge pull request #4226 from Growstuff/dev
Release 70
2025-09-09 22:23:27 +09:30
Daniel O'Connor
4c0a63bd28 Merge pull request #4235 from Growstuff/activity_name
Fix UX
2025-09-09 22:20:06 +09:30
Daniel O'Connor
7f19891428 Merge branch 'dev' into activity_name 2025-09-09 22:19:57 +09:30
Daniel O'Connor
e322871740 Fix UX 2025-09-09 12:49:17 +00:00
Daniel O'Connor
35f18556fd Merge pull request #4234 from Growstuff/activity_name
Delegate
2025-09-09 22:16:41 +09:30
Daniel O'Connor
85034298ec Merge branch 'dev' of https://github.com/Growstuff/growstuff into activity_name 2025-09-09 12:45:14 +00:00
Daniel O'Connor
b2e959aded Delegate 2025-09-09 12:44:40 +00:00
Daniel O'Connor
a4e2bf5d54 Activity name (#4233)
* Add aliases

* Add aliases
2025-09-09 22:11:18 +09:30
Daniel O'Connor
9cd00b44bb Merge branch 'dev' into activity_name 2025-09-09 22:11:06 +09:30
Daniel O'Connor
2f0b8e9d76 Add aliases 2025-09-09 12:40:06 +00:00
Daniel O'Connor
bb4e2dd788 Add aliases (#4232) 2025-09-09 22:07:24 +09:30
Daniel O'Connor
fb78bcb0b0 Add aliases 2025-09-09 12:36:22 +00:00
google-labs-jules[bot]
e5c71f1dc4 Merge pull request #4230 from Growstuff/feature/add-more-filters
feat: Add more filters to API resources
2025-09-09 21:34:49 +09:30
dependabot[bot]
2d62891ef0 Merge pull request #4227 from Growstuff/dependabot/bundler/factory_bot_rails-6.5.1 2025-09-09 09:04:42 +00:00
dependabot[bot]
cf840582d5 Bump factory_bot_rails from 6.5.0 to 6.5.1
Bumps [factory_bot_rails](https://github.com/thoughtbot/factory_bot_rails) from 6.5.0 to 6.5.1.
- [Release notes](https://github.com/thoughtbot/factory_bot_rails/releases)
- [Changelog](https://github.com/thoughtbot/factory_bot_rails/blob/main/NEWS.md)
- [Commits](https://github.com/thoughtbot/factory_bot_rails/compare/v6.5.0...v6.5.1)

---
updated-dependencies:
- dependency-name: factory_bot_rails
  dependency-version: 6.5.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 08:56:00 +00:00
Daniel O'Connor
389d904d7c Merge pull request #4231 from Growstuff/dependabot/bundler/puma-7.0.2
Bump puma from 7.0.0 to 7.0.2
2025-09-09 18:24:37 +09:30
dependabot[bot]
5bfeb0ce03 Bump puma from 7.0.0 to 7.0.2
Bumps [puma](https://github.com/puma/puma) from 7.0.0 to 7.0.2.
- [Release notes](https://github.com/puma/puma/releases)
- [Changelog](https://github.com/puma/puma/blob/master/History.md)
- [Commits](https://github.com/puma/puma/compare/v7.0.0...v7.0.2)

---
updated-dependencies:
- dependency-name: puma
  dependency-version: 7.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-09 07:01:07 +00:00
Daniel O'Connor
11b1c84985 Update garden_resource to filter by owner (#4229) 2025-09-09 08:24:15 +09:30
Daniel O'Connor
a21a9e7a09 Update garden_resource to filter by owner 2025-09-08 17:38:03 +09:30
google-labs-jules[bot]
bc11a1b8db Merge pull request #4209 from Growstuff/extend-crop-model
Extend Crop Model and Migrate Data from OpenFarm
2025-09-07 20:03:18 +09:30
Daniel O'Connor
12f6b76dca Merge pull request #4223 from Growstuff/show-history
Render history of activities on gardens, plantings
2025-09-07 19:40:18 +09:30
Daniel O'Connor
dfc75d8916 Merge pull request #4224 from Growstuff/deep-link
Harvests > Unrated Planting > Deep link to content when rating
2025-09-07 18:12:07 +09:30
Daniel O'Connor
798eb1132f Rubocop 2025-09-07 08:10:41 +00:00
Daniel O'Connor
42036a3d3f Fix logic 2025-09-07 08:07:21 +00:00
Daniel O'Connor
47da5f18c9 Merge branch 'dev' into deep-link 2025-09-07 17:35:57 +09:30
Daniel O'Connor
d22555ee42 Deep link to content when rating 2025-09-07 08:04:14 +00:00
Daniel O'Connor
d0f4911bf6 Merge pull request #4221 from Growstuff/CloCkWeRX-patch-3
Update _modal.html.haml to sort consistently
2025-09-07 17:30:17 +09:30
Daniel O'Connor
2bc164bd2e Render history 2025-09-07 07:59:51 +00:00
Daniel O'Connor
6f9fbfa3cd Merge pull request #4220 from Growstuff/CloCkWeRX-patch-2
Fix garden order on planting new
2025-09-07 17:19:05 +09:30
Daniel O'Connor
47d1877568 Merge pull request #4205 from Growstuff/dependabot/github_actions/actions/setup-node-5
Bump actions/setup-node from 4 to 5
2025-09-07 17:18:50 +09:30
Daniel O'Connor
b0555ef89e Update _modal.html.haml to sort consistently 2025-09-07 17:12:14 +09:30
Daniel O'Connor
a5f9edea87 Merge pull request #4217 from Growstuff/skip-to-content
By default, skip to content when linking to garden(s)
2025-09-07 17:09:48 +09:30
Daniel O'Connor
3917f263b8 Fix garden order on planting new 2025-09-07 17:09:27 +09:30
Daniel O'Connor
cfc486ce86 Merge pull request #4219 from Growstuff/dev
release 69.1
2025-09-07 15:07:58 +09:30
Daniel O'Connor
f55f88c4af Merge pull request #4218 from Growstuff/fix-activities
Activities > Due Date > Only render if available
2025-09-07 15:06:20 +09:30
Daniel O'Connor
3725957065 Only render if available 2025-09-07 05:34:49 +00:00
Daniel O'Connor
a900c2eb2f Merge pull request #4185 from Growstuff/dev
Release 69
2025-09-07 14:55:40 +09:30
Daniel O'Connor
8fbc02caf3 Timeline 2025-09-07 05:20:35 +00:00
dependabot[bot]
b2d8530923 Bump actions/setup-node from 4 to 5
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 5.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-07 05:19:53 +00:00
Daniel O'Connor
186f07109c Add skip link 2025-09-07 05:17:14 +00:00
Daniel O'Connor
b3c749566b More named anchors 2025-09-07 05:14:18 +00:00
Daniel O'Connor
1eb84b9765 By default, skip to content when linking to garden(s) 2025-09-07 05:11:30 +00:00
Daniel O'Connor
29543d1d37 Release 68 (#4170)
* Improve menu again

* Fix crop button annoyance

* feat: Add PWA installation instructions to homepage

This commit adds instructions for mobile users on how to install the Growstuff website as a Progressive Web App (PWA).

The changes include:
- A new section on the homepage with instructions for both iOS and Android devices. This section is only visible to logged-out users.
- New translations for the instructions in the `en.yml` locale file.
- Basic styling for the new section.
- Updated feature tests to verify the new section's visibility.

* Restyle slightly

* Styling

* Github lure

* Make links bold, not all of the stats text

* Adjust specs

* Fix width of ready to harvest

* Update spec/features/home/home_spec.rb

* Fix display

* Fix text display wonkyness

* Merge pull request #4173 from Growstuff/translate-confirm

Garden Delete - Extract strings and fix missing translation bug

* Seeds for trade - avoid showing expired seeds on homepage. (#4176)

* Improve date visibility

* Ensure when seeding seeds, it's false

* Typo

---------

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
2025-08-31 15:23:16 +09:30
Daniel O'Connor
dfb791bf55 Merge pull request #4167 from Growstuff/dev
Release67, take 3
2025-08-30 01:16:01 +09:30
Daniel O'Connor
484797421e Merge pull request #4165 from Growstuff/dev
Release 67, attempt 2
2025-08-29 23:32:29 +09:30
Daniel O'Connor
a366d68c22 Merge pull request #4160 from Growstuff/dev
Release67 - September 2025?
2025-08-29 20:03:05 +09:30
Daniel O'Connor
e7dba3f0e9 Merge pull request #4147 from Growstuff/dev
August 24 Release (Release 66)
2025-08-24 17:02:03 +09:30
72 changed files with 3990 additions and 4026 deletions

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -74,7 +74,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -89,7 +89,7 @@ jobs:
sudo apt-get -y install libpq-dev google-chrome-stable
- name: Install NodeJS
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: '12'

View File

@@ -1,5 +1,5 @@
inherit_from: .rubocop_todo.yml
require:
plugins:
- rubocop-factory_bot
- rubocop-capybara
- rubocop-rails

View File

@@ -314,7 +314,7 @@ RSpec/MultipleExpectations:
# Offense count: 138
# Configuration parameters: AllowSubject.
RSpec/MultipleMemoizedHelpers:
Max: 14
Max: 20
# Offense count: 133
# Configuration parameters: EnforcedStyle, IgnoreSharedExamples.

View File

@@ -142,7 +142,7 @@ GEM
erubi (>= 1.0.0)
rack (>= 0.9.0)
rouge (>= 1.0.0)
bigdecimal (3.2.2)
bigdecimal (3.2.3)
bluecloth (2.2.0)
bonsai-elasticsearch-rails (7.0.1)
elasticsearch-model (< 8)
@@ -198,7 +198,7 @@ GEM
comfy_bootstrap_form (4.0.9)
rails (>= 5.0.0)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
connection_pool (2.5.4)
crass (1.0.6)
crowdin-api (1.12.0)
open-uri (>= 0.1.0, < 0.2.0)
@@ -257,9 +257,9 @@ GEM
excon (1.2.5)
logger
execjs (2.10.0)
factory_bot (6.5.4)
factory_bot (6.5.5)
activesupport (>= 6.1.0)
factory_bot_rails (6.5.0)
factory_bot_rails (6.5.1)
factory_bot (~> 6.5)
railties (>= 6.1.0)
faker (3.5.2)
@@ -475,7 +475,7 @@ GEM
date
stringio
public_suffix (6.0.1)
puma (7.0.0)
puma (7.0.2)
nio4r (~> 2.0)
query_diet (0.7.2)
racc (1.8.1)

View File

@@ -29,7 +29,7 @@ class ActivitiesController < DataController
def new
@activity = Activity.new(
owner: current_member,
owner: current_member,
due_date: Date.today
)
if params[:garden_id]

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
module Api
module V1
class ActivitiesController < BaseController
end
end
end

View File

@@ -4,6 +4,40 @@ module Api
module V1
class BaseController < JSONAPI::ResourceController
abstract
protect_from_forgery with: :null_session
before_action :authenticate_member_from_token!
before_action :enforce_member_for_write_operations!, only: %i(create update destroy)
rescue_from CanCan::AccessDenied do
head :forbidden
end
def context
{
current_user: current_user,
current_ability: current_ability,
controller: self,
action: params[:action]
}
end
private
attr_reader :current_user
def enforce_member_for_write_operations!
head :unauthorized unless current_user
end
def authenticate_member_from_token!
authenticate_with_http_token do |token, _options|
auth = Authentication.find_by(token: token, provider: 'api')
if auth.present?
@current_user = auth.member
return true
end
end
end
end
end
end

View File

@@ -192,6 +192,8 @@ class CropsController < ApplicationController
:parent_id, :perennial,
:request_notes, :reason_for_rejection,
:rejection_notes,
:row_spacing, :spread, :height,
:sowing_method, :sun_requirements, :growing_degree_days,
scientific_names_attributes: %i(scientific_name _destroy id)
)
end

View File

@@ -20,6 +20,7 @@ class GardensController < DataController
def show
@current_plantings = @garden.plantings.current.where.not(failed: true).includes(:crop, :owner).order(planted_at: :desc)
@current_activities = @garden.activities.current.includes(:owner).order(created_at: :desc)
@finished_activities = @garden.activities.finished.includes(:owner).order(created_at: :desc)
@finished_plantings = @garden.plantings.finished.includes(:crop)
@suggested_companions = Crop.approved.where(
id: CropCompanion.where(crop_a_id: @current_plantings.select(:crop_id)).select(:crop_b_id)

View File

@@ -37,6 +37,7 @@ class PlantingsController < DataController
@photos = @planting.photos.includes(:owner).order(date_taken: :desc)
@harvests = Harvest.search(where: { planting_id: @planting.id })
@current_activities = @planting.activities.current.includes(:owner).order(created_at: :desc)
@finished_activities = @planting.activities.finished.includes(:owner).order(created_at: :desc)
@matching_seeds = matching_seeds
@crop = @planting.crop

View File

@@ -6,7 +6,7 @@ class RegistrationsController < Devise::RegistrationsController
prepend_before_action :check_captcha, only: [:create] # Change this to be any actions you want to protect with recaptcha.
def edit
@flickr_auth = current_member.auth('flickr')
@flickr_auth = current_member.auth('flickr')
render "edit"
end
@@ -38,6 +38,12 @@ class RegistrationsController < Devise::RegistrationsController
end
end
def regenerate_api_token
current_member.regenerate_api_token
set_flash_message :notice, :api_token_regenerated
redirect_to edit_member_registration_path + '#apps'
end
def destroy
if @member.valid_password?(params.require(:member)[:current_password])
@member.discard

View File

@@ -38,9 +38,9 @@ module ApplicationHelper
return 'today' if from_time.is_a?(Date) && (from_time == to_time)
return 'now' if from_time == to_time
return distance_of_time_in_words(from_time, to_time, include_seconds:) + ' ago' if from_time > to_time
return "#{distance_of_time_in_words(from_time, to_time, include_seconds:)} ago" if from_time < to_time
'in ' + distance_of_time_in_words(from_time, to_time, include_seconds:)
"in #{distance_of_time_in_words(from_time, to_time, include_seconds:)}"
end
def count_github_contibutors

View File

@@ -30,4 +30,20 @@ class Activity < ApplicationRecord
def to_s
name
end
def garden_name
garden&.name
end
def garden_slug
garden&.slug
end
def planting_name
planting&.crop&.name
end
def planting_slug
planting&.crop&.slug
end
end

View File

@@ -8,14 +8,6 @@ module OpenFarmData
fetch_attr('main_image_path')
end
def height
fetch_attr('height')
end
def spread
fetch_attr('spread')
end
def svg_icon
icon = fetch_attr('svg_icon')
return icon if icon.present?
@@ -31,10 +23,6 @@ module OpenFarmData
fetch_attr('description')
end
def row_spacing
fetch_attr('row_spacing')
end
def common_names
fetch_attr('common_names')
end
@@ -43,22 +31,10 @@ module OpenFarmData
fetch_attr('binomial_name')
end
def sowing_method
fetch_attr('sowing_method')
end
def main_image_path
fetch_attr('main_image_path')
end
def sun_requirements
fetch_attr('sun_requirements')
end
def growing_degree_days
fetch_attr('growing_degree_days')
end
def processing_pictures
fetch_attr('processing_pictures')
end

View File

@@ -24,6 +24,20 @@ class Member < ApplicationRecord
has_many :notifications, foreign_key: 'recipient_id', inverse_of: :recipient
has_many :sent_notifications, foreign_key: 'sender_id', inverse_of: :sender, class_name: "Notification"
has_many :authentications, dependent: :destroy
has_one :api_token, -> { where(provider: 'api') }, class_name: 'Authentication', dependent: :destroy
def api_token?
api_token.present?
end
def regenerate_api_token
api_token.destroy if api_token?
create_api_token(
provider: 'api',
uid: id,
token: SecureRandom.hex(16)
)
end
has_many :photos, inverse_of: :owner
has_many :likes, dependent: :destroy

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
module Api
module V1
class ActivityResource < BaseResource
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_one :garden
has_one :planting
attribute :name
attribute :description
attribute :category
attribute :finished
attribute :due_date
filter :owner
filter :owner_id
filter :garden
filter :garden_id
filter :planting
filter :planting_id
filter :category
end
end
end

View File

@@ -3,8 +3,7 @@
module Api
module V1
class CropResource < BaseResource
immutable
immutable # TODO: Re-evaluate this later
filter :approval_status, default: 'approved'
has_many :plantings

View File

@@ -3,13 +3,22 @@
module Api
module V1
class GardenResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_many :plantings
has_many :photos
attribute :name
filter :owner
filter :owner_id
filter :active
filter :garden_type
filter :location
filter :slug
end
end
end

View File

@@ -3,11 +3,17 @@
module Api
module V1
class HarvestResource < BaseResource
immutable
before_save do
@model.owner = context[:current_user]
@model.crop_id = @model.planting.crop_id if @model.planting_id
@model.harvested_at = Time.zone.now if @model.harvested_at.blank?
@model.plant_part = PlantPart.first
end
has_one :crop
has_one :planting
has_one :owner, class_name: 'Member'
# has_one :plant_part
has_many :photos
attribute :harvested_at
@@ -16,6 +22,15 @@ module Api
attribute :weight_quantity
attribute :weight_unit
attribute :si_weight
filter :owner
filter :owner_id
filter :crop
filter :crop_id
filter :planting
filter :planting_id
filter :plant_part
filter :harvested_at
end
end
end

View File

@@ -3,7 +3,10 @@
module Api
module V1
class PhotoResource < BaseResource
immutable
immutable # TODO: Re-evaluate this.
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_many :plantings

View File

@@ -3,7 +3,9 @@
module Api
module V1
class PlantingResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :garden
has_one :crop
@@ -36,6 +38,10 @@ module Api
filter :owner
filter :owner_id
filter :finished
filter :active, apply: ->(records, _value, _options) { records.active }
filter :failed, apply: ->(records, _value, _options) { records.failed }
filter :sunniness
filter :perennial, apply: ->(records, _value, _options) { records.perennial }
attribute :percentage_grown
delegate :percentage_grown, to: :@model

View File

@@ -3,7 +3,9 @@
module Api
module V1
class SeedResource < BaseResource
immutable
before_create do
@model.owner = context[:current_user]
end
has_one :owner, class_name: 'Member'
has_one :crop
@@ -17,6 +19,15 @@ module Api
attribute :organic
attribute :gmo
attribute :heirloom
filter :owner
filter :owner_id
filter :crop
filter :crop_id
filter :tradable_to
filter :organic
filter :gmo
filter :heirloom
end
end
end

View File

@@ -1,6 +1,16 @@
# frozen_string_literal: true
class BaseResource < JSONAPI::Resource
immutable
abstract
[:create, :update, :remove].each do |action|
set_callback action, :before, :authorize
end
# Check authorisation for write operations.
# NOTE: At a later time, we may require API tokens for READ operations.
def authorize
# context[:action] is simply context[:controller].params[:action]
context[:current_ability].authorize! context[:action].to_sym, @model
end
end

View File

@@ -23,8 +23,9 @@
.card-body
= link_to activity_path(slug: activity.slug) do
%h4= activity.name
%small.due-date{title: activity.due_date}
= standard_time_distance(activity.due_date.to_date, Time.zone.now.to_date)
- if activity.due_date
%small.due-date{title: activity.due_date}
= standard_time_distance(activity.due_date.to_date, Time.zone.now.to_date)
%div
%small.text-justify{title: activity.description}= activity.description.truncate(150)
%p

View File

@@ -41,6 +41,14 @@
= f.radio_button(:perennial, true, label: "Perennial")
%span.help-block Living more than two years
%h2 OpenFarm Data
= f.number_field :row_spacing, label: 'Row Spacing (cm)', min: 0
= f.number_field :spread, label: 'Spread (cm)', min: 0
= f.number_field :height, label: 'Height (cm)', min: 0
= f.text_field :sowing_method
= f.text_field :sun_requirements
= f.number_field :growing_degree_days, min: 0
- unless @crop.approved?
= link_to 'Search wikipedia', "https://en.wikipedia.org/w/index.php?search=#{@crop.name}", target: '_blank'
= f.url_field :en_wikipedia_url, id: "en_wikipedia_url", label: 'Wikipedia URL'

View File

@@ -0,0 +1,33 @@
- if crop.row_spacing || crop.spread || crop.height || crop.sowing_method || crop.sun_requirements || crop.growing_degree_days
= cute_icon
.card
.card-body
%h4 OpenFarm Data
%ul.list-group.list-group-flush
- if crop.row_spacing
%li.list-group-item
%strong Row Spacing:
= crop.row_spacing
cm
- if crop.spread
%li.list-group-item
%strong Spread:
= crop.spread
cm
- if crop.height
%li.list-group-item
%strong Height:
= crop.height
cm
- if crop.sowing_method
%li.list-group-item
%strong Sowing Method:
= crop.sowing_method
- if crop.sun_requirements
%li.list-group-item
%strong Sun Requirements:
= crop.sun_requirements
- if crop.growing_degree_days
%li.list-group-item
%strong Growing Degree Days:
= crop.growing_degree_days

View File

@@ -111,6 +111,8 @@
= render 'harvests', crop: @crop
= render 'find_seeds', crop: @crop
= render 'openfarm_data', crop: @crop
= cute_icon
.card
.card-body

View File

@@ -15,3 +15,16 @@
method: :delete, class: "remove btn btn-danger"
- else
= link_to 'Connect to Flickr', '/members/auth/flickr', class: 'btn'
%hr
.row
.col-md-12
%p
= image_tag "icons/post.svg", size: "32x32", alt: 'API logo'
- if current_member.api_token?
Your API token is
%code= current_member.api_token.token
= link_to "Regenerate", regenerate_api_token_path,
data: { confirm: "Are you sure? Your old token will stop working immediately." },
method: :post, class: "remove btn btn-danger"
- else
= link_to 'Generate API Token', regenerate_api_token_path, method: :post, class: 'btn btn-primary'

View File

@@ -16,3 +16,8 @@
.col-md-12
%p Nothing has been planted here.
- if @finished_activities&.size&.positive?
%h2 Finished activities in garden
.index-cards
- @finished_activities.each do |activity|
= render "activities/card", activity: activity

View File

@@ -11,6 +11,9 @@
.row
.col-md-2
%small
%a{href: "#content"}
Skip to main content
= render 'layouts/nav', model: Garden
%label
= link_to show_inactive_tickbox_path('gardens', owner: @owner, show_all: @show_all) do
@@ -20,7 +23,7 @@
%hr/
= render @owner
.col-md-10
.col-md-10#content
- if @gardens.empty?
%p There are no gardens to display.
- if can?(:create, Garden) && @owner == current_member

View File

@@ -9,4 +9,4 @@
- if @harvest.planting.present? && @harvest.planting.overall_rating.blank?
.alert.alert-info{role: "alert"}
This harvest is from a planting that hasn't been rated yet.
= link_to "Rate this planting", edit_planting_path(@harvest.planting), class: 'alert-link'
= link_to "Rate this planting", edit_planting_path(@harvest.planting, anchor: "planting_overall_rating"), class: 'alert-link'

View File

@@ -11,11 +11,11 @@
%br
%p
- if current_member.plantings.active.any?
= link_to member_path(current_member, anchor: "#content"), class: 'btn btn-dark' do
= link_to member_path(current_member, anchor: "content"), class: 'btn btn-dark' do
= planting_icon
Track my plantings
%p
= link_to member_gardens_path(current_member), class: 'btn btn-dark' do
= link_to member_gardens_path(current_member, anchor: "content"), class: 'btn btn-dark' do
= garden_icon
Show me my garden
- else

View File

@@ -2,10 +2,10 @@
%ul.navbar-nav.mr-auto.bg-dark
- if signed_in?
%li.nav-item
= link_to timeline_index_path, method: :get, class: 'nav-link text-white' do
= link_to timeline_index_path, method: :get, class: 'nav-link text-white', title: "Timeline" do
= image_tag 'icons/notification.svg', class: 'img img-icon', alt: "Notifications"
%li.nav-item
= link_to member_gardens_path(current_member), class: 'nav-link text-white', title: "My gardens" do
= link_to member_gardens_path(current_member, anchor: "content"), class: 'nav-link text-white', title: "My gardens" do
= image_icon 'gardens'
%li.nav-item.dropdown
%a.nav-link.dropdown-toggle{"aria-expanded" => "false", "aria-haspopup" => "true", "data-bs-toggle" => "dropdown", href: "#", role: "button"}

View File

@@ -83,14 +83,14 @@
.row
%section.order-3.order-md-1.col-12= render "map", member: @member
- if @harvesting.size.positive?
%section.harvests.order-2.order-md-1.col-12
%section.harvests.order-2.order-md-1.col-12#harvests
%h2 Ready to harvest
.index-cards
- @harvesting.each do |planting|
= render 'plantings/thumbnail', planting: planting
- if @others.size.positive?
%section.planting-progress.order-2.order-md-1.col-12
%section.planting-progress.order-2.order-md-1.col-12#planting-progress
%h2 Progress report
%p Still growing and not ready for harvesting.
.list-group
@@ -99,7 +99,7 @@
%span= render 'plantings/tiny', planting: planting
%span= render 'plantings/progress', planting: planting
- if @late.size.positive?
%section.late.order-2.order-md-1.col-12
%section.late.order-2.order-md-1.col-12#late
%h2 Late
%p
These plantings are at the end of their lifecycle.
@@ -109,7 +109,7 @@
- @late.each do |planting|
= render 'plantings/thumbnail', planting: planting
- if @super_late.any?
%section.superlate.order-2.order-md-1.col-12
%section.superlate.order-2.order-md-1.col-12#superlate
%h2 Super late
%p
We suspect the following plantings finished long ago and no longer need tracking.
@@ -122,14 +122,14 @@
planted on #{planting.planted_at.to_date}
- if @harvests.any?
%section.havests.order-2.order-md-1.col-12
%section.havests.order-2.order-md-1.col-12#recent-harvests
%h2 Recent Harvests
.index-cards
- @harvests.each do |harvest|
= render 'harvests/thumbnail', harvest: harvest
- if @activity.any?
%section.activity.order-2.order-md-1.col-12
%section.activity.order-2.order-md-1.col-12#activity
%h2 Activity
.list-group
- @activity.each do |event|

View File

@@ -27,7 +27,7 @@
.row
.col-md-8
= f.collection_radio_buttons(:garden_id, @planting.owner.gardens.active,
= f.collection_radio_buttons(:garden_id, @planting.owner.gardens.active.order_by_name,
:id, :name, required: true,
label: 'Where did you plant it?')
= link_to "Add a garden.", new_garden_path

View File

@@ -9,7 +9,7 @@
%p Which garden is the planting in?
%ul.list-group
- planting.owner.gardens.active.order(:name).each do |garden|
- planting.owner.gardens.active.order_by_name.each do |garden|
%li.list-group-item
= link_to plantings_path(planting: {crop_id: planting.crop_id, garden_id: garden.id}), method: :post do
.md-v-line

View File

@@ -89,7 +89,11 @@
- else
.col-md-12
%p Nothing is currently planned here.
- if @finished_activities&.size&.positive?
%h2 Finished activities for planting
.index-cards
- @finished_activities.each do |activity|
= render "activities/card", activity: activity
.col-md-4.col-xs-12
= render @planting.crop

View File

@@ -1,31 +1,27 @@
development:
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
development:
<<: *default
database: growstuff_dev
user: postgres
password: postgres
host: db
host: <%= ENV.fetch("DATABASE_HOST") { 'db' } %>
test:
adapter: postgresql
<<: *default
database: growstuff_test
user: postgres
password: postgres
host: db
host: <%= ENV.fetch("DATABASE_HOST") { 'db' } %>
production:
adapter: postgresql
database: growstuff_prod
pool: 5
timeout: 5000
username: growstuff
host: localhost
password: thisisnottherealpassword
<<: *default
url: <%= ENV['DATABASE_URL'] %>
staging:
adapter: postgresql
database: growstuff_prod
pool: 5
timeout: 5000
username: growstuff
host: localhost
password: thisisnottherealpassword
<<: *default
url: <%= ENV['DATABASE_URL'] %>

View File

@@ -1,8 +1,10 @@
# frozen_string_literal: true
class UnauthorisedError < JSONAPI::Error
end
JSONAPI.configure do |config|
# built in paginators are :none, :offset, :paged
config.default_paginator = :offset
config.default_page_size = 10
config.maximum_page_size = 100
config.exception_class_whitelist = [CanCan::AccessDenied, UnauthorisedError]
end

View File

@@ -54,6 +54,7 @@ en:
You updated your account successfully, but we need to verify your new email address. Please check your email and click on the confirm
link to finalize confirming your new email address.
destroyed: 'Bye! Your account was successfully cancelled. We hope to see you again soon.'
api_token_regenerated: 'Your API token has been regenerated.'
unlocks:
send_instructions: 'You will receive an email with instructions about how to unlock your account in a few minutes.'
unlocked: 'Your account has been unlocked successfully. Please sign in to continue.'

View File

@@ -16,6 +16,7 @@ Rails.application.routes.draw do
}
devise_scope :member do
get '/members/unsubscribe/:message' => 'members#unsubscribe', as: 'unsubscribe_member'
post '/members/regenerate_api_token' => 'registrations#regenerate_api_token', as: 'regenerate_api_token'
end
match '/members/:id/finish_signup' => 'members#finish_signup', via: %i(get patch), as: :finish_signup
@@ -141,6 +142,7 @@ Rails.application.routes.draw do
namespace :api do
namespace :v1 do
jsonapi_resources :activities
jsonapi_resources :crops
jsonapi_resources :gardens
jsonapi_resources :harvests

View File

@@ -0,0 +1,10 @@
class AddFieldsToCrops < ActiveRecord::Migration[5.2]
def change
add_column :crops, :row_spacing, :integer
add_column :crops, :spread, :integer
add_column :crops, :height, :integer
add_column :crops, :sowing_method, :string
add_column :crops, :sun_requirements, :string
add_column :crops, :growing_degree_days, :integer
end
end

View File

@@ -0,0 +1,21 @@
class PopulateCropFieldsFromOpenfarmData < ActiveRecord::Migration[5.2]
def up
Crop.find_each do |crop|
if crop.openfarm_data.present?
attributes = crop.openfarm_data.fetch('attributes', {})
crop.update_columns(
row_spacing: attributes['row_spacing'],
spread: attributes['spread'],
height: attributes['height'],
sowing_method: attributes['sowing_method'],
sun_requirements: attributes['sun_requirements'],
growing_degree_days: attributes['growing_degree_days']
)
end
end
end
def down
# This migration is not reversible.
end
end

View File

@@ -252,6 +252,12 @@ ActiveRecord::Schema[7.2].define(version: 2025_09_01_130830) do
t.jsonb "openfarm_data"
t.integer "harvests_count", default: 0
t.integer "photo_associations_count", default: 0
t.integer "row_spacing"
t.integer "spread"
t.integer "height"
t.string "sowing_method"
t.string "sun_requirements"
t.integer "growing_degree_days"
t.index ["creator_id"], name: "index_crops_on_creator_id"
t.index ["name"], name: "index_crops_on_name"
t.index ["parent_id"], name: "index_crops_on_parent_id"

View File

@@ -36,21 +36,21 @@ namespace :wikidata do
aliases = wikidata_data['entities'][wikidata_id]['aliases']
aliases.each do |lang, values|
values.each do |value|
unless AlternateName.exists?(name: value['value'], language: lang, crop: crop)
AlternateName.create!(
name: value['value'],
language: lang,
crop: crop,
creator: creator
)
puts " Added alternate name: #{value['value']} (#{lang})"
end
next if AlternateName.exists?(name: value['value'], language: lang, crop: crop)
AlternateName.create!(
name: value['value'],
language: lang,
crop: crop,
creator: creator
)
puts " Added alternate name: #{value['value']} (#{lang})"
end
end
else
puts " Could not find Wikidata ID for #{crop.name}"
end
rescue => e
rescue StandardError => e
puts " Error processing crop #{crop.name}: #{e.message}"
end
end

View File

@@ -100,6 +100,36 @@ describe CropsController do
it { expect { subject }.to change(Crop, :count).by(1) }
it { expect { subject }.to change(AlternateName, :count).by(2) }
it { expect { subject }.to change(ScientificName, :count).by(1) }
context 'with openfarm data' do
let(:crop_params) do
{
crop: {
name: 'aubergine',
en_wikipedia_url: "https://en.wikipedia.org/wiki/Eggplant",
row_spacing: 10,
spread: 20,
height: 30,
sowing_method: 'direct',
sun_requirements: 'full sun',
growing_degree_days: 100
},
alt_name: { '1': "egg plant", '2': "purple apple" },
sci_name: { '1': "fancy sci name", '2': "" }
}
end
it 'saves openfarm data' do
subject
crop = Crop.last
expect(crop.row_spacing).to eq(10)
expect(crop.spread).to eq(20)
expect(crop.height).to eq(30)
expect(crop.sowing_method).to eq('direct')
expect(crop.sun_requirements).to eq('full sun')
expect(crop.growing_degree_days).to eq(100)
end
end
end
end

View File

@@ -19,6 +19,14 @@ describe "Crop", :js do
click_button class: "add-altname-row"
fill_in "alt_name[3]", with: "Jazmin"
fill_in "alt_name[4]", with: "Matsurika"
fill_in "crop_row_spacing", with: "12"
fill_in "crop_spread", with: "30"
fill_in "crop_height", with: "10"
fill_in "crop_sowing_method", with: "directly into final position"
fill_in "crop_sun_requirements", with: "full sun"
fill_in "crop_growing_degree_days", with: 100
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
describe "member token management", :js do
include_context 'signed in member'
before do
visit edit_member_registration_path
click_on "Apps"
end
it "can generate an API token" do
expect(page).to have_no_content("Your API token is")
click_on "Generate API Token"
expect(page).to have_content("Your API token is")
member.reload
expect(member.api_token).to be_present
end
context "with an existing token" do
before do
member.regenerate_api_token
visit edit_member_registration_path
click_on "Apps"
end
it "can regenerate an API token" do
old_token = member.api_token.token
expect(page).to have_content("Your API token is")
accept_confirm do
click_on "Regenerate"
end
expect(page).to have_content("Your API token is")
expect(member.reload.api_token.token).not_to eq(old_token)
end
end
end

View File

@@ -0,0 +1,95 @@
# frozen_string_literal: true
require 'swagger_helper'
RSpec.describe 'Activities API', type: :request do
path '/api/v1/activities' do
get 'Lists activities' do
tags 'Activities'
produces 'application/vnd.api+json'
parameter name: 'filter[owner-id]', in: :query, type: :string, required: false
parameter name: 'filter[garden-id]', in: :query, type: :string, required: false
parameter name: 'filter[planting-id]', in: :query, type: :string, required: false
parameter name: 'filter[category]', in: :query, type: :string, required: false
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string },
description: { type: :string },
category: { type: :string },
finished: { type: :boolean },
'due-date': { type: :string, format: 'date-time' }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
garden: { '$ref' => '#/components/schemas/relationship' },
planting: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let!(:activity) { FactoryBot.create(:activity, garden: create(:garden), planting: create(:planting)) }
run_test!
end
end
end
path '/api/v1/activities/{id}' do
get 'Retrieves an activity' do
tags 'Activities'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string },
description: { type: :string },
category: { type: :string },
finished: { type: :boolean },
'due-date': { type: :string, format: 'date-time' }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
garden: { '$ref' => '#/components/schemas/relationship' },
planting: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:activity) { FactoryBot.create(:activity, garden: create(:garden), planting: create(:planting)) }
let(:id) { activity.id }
run_test!
end
end
end
end

View File

@@ -1,103 +1,98 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Crops', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Crops API', type: :request do
path '/api/v1/crops' do
get 'Lists crops' do
tags 'Crops'
produces 'application/vnd.api+json'
parameter name: 'filter[approval_status]', in: :query, type: :string, required: false, description: 'Filter by approval status. Defaults to "approved".'
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:crop) { FactoryBot.create(:crop) }
let(:crop_encoded_as_json_api) do
{ "id" => crop.id.to_s,
"type" => "crops",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"plantings" => plantings_as_json_api,
"parent" => parent_as_json_api,
"harvests" => harvests_as_json_api,
"seeds" => seeds_as_json_api,
"photos" => photos_as_json_api
} }
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string },
'en-wikipedia-url': { type: :string, format: 'uri', 'x-nullable': true },
perennial: { type: :boolean, 'x-nullable': true },
'median-lifespan': { type: :integer, 'x-nullable': true },
'median-days-to-first-harvest': { type: :integer, 'x-nullable': true },
'median-days-to-last-harvest': { type: :integer, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
plantings: { '$ref' => '#/components/schemas/relationship' },
parent: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' },
seeds: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let!(:crop) { FactoryBot.create(:crop) }
run_test!
end
end
end
let(:resource_url) { "http://www.example.com/api/v1/crops/#{crop.id}" }
path '/api/v1/crops/{id}' do
get 'Retrieves a crop' do
tags 'Crops'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
let(:seeds_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/seeds",
"related" => "#{resource_url}/seeds" } }
end
let(:harvests_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/harvests",
"related" => "#{resource_url}/harvests" } }
end
let(:parent_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/parent",
"related" => "#{resource_url}/parent" } }
end
let(:plantings_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/plantings",
"related" => "#{resource_url}/plantings" } }
end
let(:photos_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/photos",
"related" => "#{resource_url}/photos" } }
end
let(:attributes) do
{
"name" => crop.name,
"en-wikipedia-url" => crop.en_wikipedia_url,
"perennial" => false,
"median-lifespan" => nil,
"median-days-to-first-harvest" => nil,
"median-days-to-last-harvest" => nil
}
end
describe '#index' do
before { get '/api/v1/crops', params: {}, headers: }
it { expect(subject['data']).to include(crop_encoded_as_json_api) }
end
describe '#show' do
before { get "/api/v1/crops/#{crop.id}", params: {}, headers: }
it { expect(subject['data']['attributes']).to eq(attributes) }
it { expect(subject['data']['relationships']).to include("plantings" => plantings_as_json_api) }
it { expect(subject['data']['relationships']).to include("harvests" => harvests_as_json_api) }
it { expect(subject['data']['relationships']).to include("seeds" => seeds_as_json_api) }
it { expect(subject['data']['relationships']).to include("photos" => photos_as_json_api) }
it { expect(subject['data']['relationships']).to include("parent" => parent_as_json_api) }
it { expect(subject['data']).to eq(crop_encoded_as_json_api) }
end
it '#create' do
expect do
post '/api/v1/crops', params: { 'crop' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#update' do
expect do
post "/api/v1/crops/#{crop.id}", params: { 'crop' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/crops/#{crop.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string },
'en-wikipedia-url': { type: :string, format: 'uri', 'x-nullable': true },
perennial: { type: :boolean, 'x-nullable': true },
'median-lifespan': { type: :integer, 'x-nullable': true },
'median-days-to-first-harvest': { type: :integer, 'x-nullable': true },
'median-days-to-last-harvest': { type: :integer, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
plantings: { '$ref' => '#/components/schemas/relationship' },
parent: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' },
seeds: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:crop) { FactoryBot.create(:crop) }
let(:id) { crop.id }
run_test!
end
end
end
end

View File

@@ -1,70 +1,223 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Gardens', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Gardens API', type: :request do
path '/api/v1/gardens' do
get 'Lists gardens' do
tags 'Gardens'
produces 'application/vnd.api+json'
parameter name: 'filter[active]', in: :query, type: :string, required: false
parameter name: 'filter[garden_type]', in: :query, type: :string, required: false
parameter name: 'filter[owner_id]', in: :query, type: :string, required: false
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:garden) { FactoryBot.create(:garden) }
let(:garden_encoded_as_json_api) do
{ "id" => garden.id.to_s,
"type" => "gardens",
"links" => { "self" => resource_url },
"attributes" => { "name" => garden.name },
"relationships" =>
{
"owner" => owner_as_json_api,
"plantings" => plantings_as_json_api,
"photos" => photos_as_json_api
} }
end
let(:resource_url) { "http://www.example.com/api/v1/gardens/#{garden.id}" }
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let(:plantings_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/plantings",
"related" => "#{resource_url}/plantings" } }
let!(:garden) { FactoryBot.create(:garden) }
run_test!
end
end
post 'Creates a garden' do
tags 'Gardens'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :garden, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string }
},
required: ['name']
}
},
required: ['type', 'attributes']
}
},
required: ['data']
}
response '201', 'created' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:garden) { { data: { type: 'gardens', attributes: { name: 'My API Garden' } } } }
run_test!
end
response '401', 'unauthorized' do
let(:garden) { { data: { type: 'gardens', attributes: { name: 'My API Garden' } } } }
run_test!
end
end
end
let(:owner_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/owner",
"related" => "#{resource_url}/owner" } }
end
path '/api/v1/gardens/{id}' do
get 'Retrieves a garden' do
tags 'Gardens'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
let(:photos_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/photos",
"related" => "#{resource_url}/photos" } }
end
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:garden) { FactoryBot.create(:garden) }
let(:id) { garden.id }
run_test!
end
end
it '#index' do
get('/api/v1/gardens', params: {}, headers:)
expect(subject['data']).to include(garden_encoded_as_json_api)
end
patch 'Updates a garden' do
tags 'Gardens'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
parameter name: :garden, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string },
attributes: {
type: :object,
properties: {
name: { type: :string }
}
}
},
required: ['type', 'id']
}
},
required: ['data']
}
it '#show' do
get("/api/v1/gardens/#{garden.id}", params: {}, headers:)
expect(subject['data']).to include(garden_encoded_as_json_api)
end
response '200', 'ok' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:garden_to_update) { create(:garden, owner: member) }
let(:id) { garden_to_update.id }
let(:garden) { { data: { type: 'gardens', id: id, attributes: { name: 'An updated garden' } } } }
run_test!
end
it '#create' do
expect do
post '/api/v1/gardens', params: { 'garden' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
response '401', 'unauthorized' do
let(:garden_to_update) { create(:garden) }
let(:id) { garden_to_update.id }
let(:garden) { { data: { type: 'gardens', id: id, attributes: { name: 'An updated garden' } } } }
run_test!
end
it '#update' do
expect do
post "/api/v1/gardens/#{garden.id}", params: { 'garden' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_garden) { create(:garden) }
let(:id) { other_member_garden.id }
let(:garden) { { data: { type: 'gardens', id: id, attributes: { name: 'An updated garden' } } } }
run_test!
end
end
it '#delete' do
expect do
delete "/api/v1/gardens/#{garden.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
delete 'Deletes a garden' do
tags 'Gardens'
parameter name: :id, in: :path, type: :string
response '204', 'no content' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:garden_to_delete) { create(:garden, owner: member) }
let(:id) { garden_to_delete.id }
run_test!
end
response '401', 'unauthorized' do
let(:garden_to_delete) { create(:garden) }
let(:id) { garden_to_delete.id }
run_test!
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_garden) { create(:garden) }
let(:id) { other_member_garden.id }
run_test!
end
end
end
end

View File

@@ -1,100 +1,257 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Harvests', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Harvests API', type: :request do
path '/api/v1/harvests' do
get 'Lists harvests' do
tags 'Harvests'
produces 'application/vnd.api+json'
parameter name: 'filter[crop_id]', in: :query, type: :string, required: false
parameter name: 'filter[planting_id]', in: :query, type: :string, required: false
parameter name: 'filter[plant_part]', in: :query, type: :string, required: false
parameter name: 'filter[owner_id]', in: :query, type: :string, required: false
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:harvest) { FactoryBot.create(:harvest) }
let(:harvest_encoded_as_json_api) do
{ "id" => harvest.id.to_s,
"type" => "harvests",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"crop" => crop_as_json_api,
"planting" => planting_as_json_api,
"owner" => owner_as_json_api,
"photos" => photos_as_json_api
} }
end
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'harvested-at': { type: :string, format: 'date' },
description: { type: :string, 'x-nullable': true },
unit: { type: :string, 'x-nullable': true },
'weight-quantity': { type: :string, 'x-nullable': true },
'weight-unit': { type: :string, 'x-nullable': true },
'si-weight': { type: :number, format: :float, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
crop: { '$ref' => '#/components/schemas/relationship' },
planting: { '$ref' => '#/components/schemas/relationship' },
owner: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let(:resource_url) { "http://www.example.com/api/v1/harvests/#{harvest.id}" }
let!(:harvest) { FactoryBot.create(:harvest) }
run_test!
end
end
let(:crop_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/crop",
"related" => "#{resource_url}/crop" } }
end
let(:owner_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/owner",
"related" => "#{resource_url}/owner" } }
end
let(:planting_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/planting",
"related" => "#{resource_url}/planting" } }
end
let(:photos_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/photos",
"related" => "#{resource_url}/photos" } }
end
let(:attributes) do
{
"harvested-at" => "2015-09-17",
"description" => harvest.description,
"unit" => harvest.unit,
"weight-quantity" => harvest.weight_quantity.to_s,
"weight-unit" => harvest.weight_unit,
"si-weight" => harvest.si_weight
}
end
describe '#index' do
before { get '/api/v1/harvests', params: {}, headers: }
it { expect(subject['data']).to include(harvest_encoded_as_json_api) }
end
describe '#show' do
before { get "/api/v1/harvests/#{harvest.id}", params: {}, headers: }
it { expect(subject['data']['attributes']).to eq(attributes) }
it { expect(subject['data']['relationships']).to include("planting" => planting_as_json_api) }
it { expect(subject['data']['relationships']).to include("crop" => crop_as_json_api) }
it { expect(subject['data']['relationships']).to include("photos" => photos_as_json_api) }
it { expect(subject['data']['relationships']).to include("owner" => owner_as_json_api) }
it { expect(subject['data']).to eq(harvest_encoded_as_json_api) }
end
it '#create' do
expect do
put '/api/v1/harvests', headers:, params: {
'harvest' => { 'description' => 'can i make this' }
post 'Creates a harvest' do
tags 'Harvests'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :harvest, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
},
relationships: {
type: :object,
properties: {
planting: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string }
},
required: ['type', 'id']
}
},
required: ['data']
}
},
required: ['planting']
}
},
required: ['type', 'attributes', 'relationships']
}
},
required: ['data']
}
end.to raise_error ActionController::RoutingError
response '201', 'created' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:planting) { create(:planting, owner: member) }
let(:harvest) { { data: { type: 'harvests', attributes: { description: 'My API harvest' }, relationships: { planting: { data: { type: 'plantings', id: planting.id } } } } } }
run_test!
end
response '401', 'unauthorized' do
let(:planting) { create(:planting) }
let(:harvest) { { data: { type: 'harvests', attributes: { description: 'My API harvest' }, relationships: { planting: { data: { type: 'plantings', id: planting.id } } } } } }
run_test!
end
end
end
it '#update' do
expect do
post "/api/v1/harvests/#{harvest.id}", headers:, params: {
'harvest' => { 'description' => 'can i modify this' }
path '/api/v1/harvests/{id}' do
get 'Retrieves a harvest' do
tags 'Harvests'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'harvested-at': { type: :string, format: 'date' },
description: { type: :string, 'x-nullable': true },
unit: { type: :string, 'x-nullable': true },
'weight-quantity': { type: :string, 'x-nullable': true },
'weight-unit': { type: :string, 'x-nullable': true },
'si-weight': { type: :number, format: :float, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
crop: { '$ref' => '#/components/schemas/relationship' },
planting: { '$ref' => '#/components/schemas/relationship' },
owner: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:harvest) { FactoryBot.create(:harvest) }
let(:id) { harvest.id }
run_test!
end
end
patch 'Updates a harvest' do
tags 'Harvests'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
parameter name: :harvest, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
}
},
required: ['type', 'id']
}
},
required: ['data']
}
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/harvests/#{harvest.id}", headers:, params: {}
end.to raise_error ActionController::RoutingError
response '200', 'ok' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:harvest_to_update) { create(:harvest, owner: member) }
let(:id) { harvest_to_update.id }
let(:harvest) { { data: { type: 'harvests', id: id, attributes: { description: 'An updated harvest' } } } }
run_test!
end
response '401', 'unauthorized' do
let(:harvest_to_update) { create(:harvest) }
let(:id) { harvest_to_update.id }
let(:harvest) { { data: { type: 'harvests', id: id, attributes: { description: 'An updated harvest' } } } }
run_test!
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_harvest) { create(:harvest) }
let(:id) { other_member_harvest.id }
let(:harvest) { { data: { type: 'harvests', id: id, attributes: { description: 'An updated harvest' } } } }
run_test!
end
end
delete 'Deletes a harvest' do
tags 'Harvests'
parameter name: :id, in: :path, type: :string
response '204', 'no content' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:harvest_to_delete) { create(:harvest, owner: member) }
let(:id) { harvest_to_delete.id }
run_test!
end
response '401', 'unauthorized' do
let(:harvest_to_delete) { create(:harvest) }
let(:id) { harvest_to_delete.id }
run_test!
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_harvest) { create(:harvest) }
let(:id) { other_member_harvest.id }
run_test!
end
end
end
end

View File

@@ -1,100 +1,91 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Members', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Members API', type: :request do
path '/api/v1/members' do
get 'Lists members' do
tags 'Members'
produces 'application/vnd.api+json'
parameter name: 'filter[login_name]', in: :query, type: :string, required: false
parameter name: 'filter[slug]', in: :query, type: :string, required: false
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:member) { FactoryBot.create(:member) }
let(:member_encoded_as_json_api) do
{ "id" => member.id.to_s,
"type" => "members",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"gardens" => gardens_as_json_api,
"harvests" => harvests_as_json_api,
"photos" => photos_as_json_api,
"plantings" => plantings_as_json_api,
"seeds" => seeds_as_json_api
} }
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'login-name': { type: :string },
slug: { type: :string }
}
},
relationships: {
type: :object,
properties: {
gardens: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
seeds: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let!(:member) { FactoryBot.create(:member) }
run_test!
end
end
end
let(:resource_url) { "http://www.example.com/api/v1/members/#{member.id}" }
path '/api/v1/members/{id}' do
get 'Retrieves a member' do
tags 'Members'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
let(:harvests_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/harvests",
"related" => "#{resource_url}/harvests" } }
end
let(:photos_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/photos",
"related" => "#{resource_url}/photos" } }
end
let(:seeds_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/seeds",
"related" => "#{resource_url}/seeds" } }
end
let(:plantings_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/plantings",
"related" => "#{resource_url}/plantings" } }
end
let(:gardens_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/gardens",
"related" => "#{resource_url}/gardens" } }
end
let(:attributes) do
{
"login-name" => member.login_name,
"slug" => member.slug
}
end
describe '#index' do
before { get '/api/v1/members', params: {}, headers: }
it { expect(subject['data']).to include(member_encoded_as_json_api) }
end
describe '#show' do
before { get "/api/v1/members/#{member.id}", params: {}, headers: }
it { expect(subject['data']['relationships']).to include("gardens" => gardens_as_json_api) }
it { expect(subject['data']['relationships']).to include("plantings" => plantings_as_json_api) }
it { expect(subject['data']['relationships']).to include("seeds" => seeds_as_json_api) }
it { expect(subject['data']['relationships']).to include("harvests" => harvests_as_json_api) }
it { expect(subject['data']['relationships']).to include("photos" => photos_as_json_api) }
it { expect(subject['data']).to eq(member_encoded_as_json_api) }
end
it '#create' do
expect do
post '/api/v1/members', params: { 'member' => { 'login_name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#update' do
expect do
post "/api/v1/members/#{member.id}", params: {
'member' => { 'login_name' => 'can i modify this' }
},
headers:
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/members/#{member.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'login-name': { type: :string },
slug: { type: :string }
}
},
relationships: {
type: :object,
properties: {
gardens: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
seeds: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:member) { FactoryBot.create(:member) }
let(:id) { member.id }
run_test!
end
end
end
end

View File

@@ -1,93 +1,93 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Photos', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Photos API', type: :request do
path '/api/v1/photos' do
get 'Lists photos' do
tags 'Photos'
produces 'application/vnd.api+json'
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:photo) { FactoryBot.create(:photo) }
let(:photo_encoded_as_json_api) do
{ "id" => photo.id.to_s,
"type" => "photos",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"owner" => owner_as_json_api,
"plantings" => plantings_as_json_api,
"harvests" => harvests_as_json_api,
"gardens" => gardens_as_json_api
} }
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'thumbnail-url': { type: :string, format: :uri },
'fullsize-url': { type: :string, format: :uri },
'license-name': { type: :string },
'link-url': { type: :string, format: :uri },
title: { type: :string }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
gardens: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let!(:photo) { FactoryBot.create(:photo) }
run_test!
end
end
end
let(:resource_url) { "http://www.example.com/api/v1/photos/#{photo.id}" }
path '/api/v1/photos/{id}' do
get 'Retrieves a photo' do
tags 'Photos'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
let(:owner_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/owner",
"related" => "#{resource_url}/owner" } }
end
let(:harvests_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/harvests",
"related" => "#{resource_url}/harvests" } }
end
let(:gardens_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/gardens",
"related" => "#{resource_url}/gardens" } }
end
let(:plantings_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/plantings",
"related" => "#{resource_url}/plantings" } }
end
let(:attributes) do
{
"thumbnail-url" => photo.thumbnail_url,
"fullsize-url" => photo.fullsize_url,
"link-url" => photo.link_url,
"license-name" => photo.license_name,
"title" => photo.title
}
end
describe '#index' do
before { get '/api/v1/photos', params: {}, headers: }
it { expect(subject['data']).to include(photo_encoded_as_json_api) }
end
describe '#show' do
before { get "/api/v1/photos/#{photo.id}", params: {}, headers: }
it { expect(subject['data']['attributes']).to eq(attributes) }
it { expect(subject['data']['relationships']).to include("plantings" => plantings_as_json_api) }
it { expect(subject['data']['relationships']).to include("harvests" => harvests_as_json_api) }
it { expect(subject['data']['relationships']).to include("owner" => owner_as_json_api) }
it { expect(subject['data']).to eq(photo_encoded_as_json_api) }
end
it '#create' do
expect do
post '/api/v1/photos', params: { 'photo' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#update' do
expect do
post "/api/v1/photos/#{photo.id}", params: { 'photo' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/photos/#{photo.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
'thumbnail-url': { type: :string, format: :uri },
'fullsize-url': { type: :string, format: :uri },
'license-name': { type: :string },
'link-url': { type: :string, format: :uri },
title: { type: :string }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
plantings: { '$ref' => '#/components/schemas/relationship' },
gardens: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:photo) { FactoryBot.create(:photo) }
let(:id) { photo.id }
run_test!
end
end
end
end

View File

@@ -1,142 +1,300 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Plantings', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Plantings API', type: :request do
path '/api/v1/plantings' do
get 'Lists plantings' do
tags 'Plantings'
produces 'application/vnd.api+json'
parameter name: 'filter[failed]', in: :query, type: :string, required: false
parameter name: 'filter[sunniness]', in: :query, type: :string, required: false
parameter name: 'filter[perennial]', in: :query, type: :string, required: false
parameter name: 'filter[active]', in: :query, type: :string, required: false
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:planting) { FactoryBot.create(:planting) }
let(:planting_encoded_as_json_api) do
{ "id" => planting.id.to_s,
"type" => "plantings",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"garden" => garden_as_json_api,
"crop" => crop_as_json_api,
"owner" => owner_as_json_api,
"photos" => photos_as_json_api,
"harvests" => harvests_as_json_api
} }
end
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
slug: { type: :string },
'planted-at': { type: :string, format: 'date' },
failed: { type: :boolean },
finished: { type: :boolean },
'finished-at': { type: :string, format: 'date-time', 'x-nullable': true },
quantity: { type: :integer },
description: { type: :string, 'x-nullable': true },
sunniness: { type: :string, 'x-nullable': true },
'planted-from': { type: :string, 'x-nullable': true },
'expected-lifespan': { type: :integer, 'x-nullable': true },
'finish-predicted-at': { type: :string, format: 'date-time', 'x-nullable': true },
'first-harvest-date': { type: :string, format: 'date', 'x-nullable': true },
'last-harvest-date': { type: :string, format: 'date', 'x-nullable': true },
'crop-name': { type: :string },
'crop-slug': { type: :string },
thumbnail: { type: :string, format: :uri, 'x-nullable': true },
location: { type: :string, 'x-nullable': true },
longitude: { type: :number, format: :float, 'x-nullable': true },
latitude: { type: :number, format: :float, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
garden: { '$ref' => '#/components/schemas/relationship' },
crop: { '$ref' => '#/components/schemas/relationship' },
owner: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let(:resource_url) { "http://www.example.com/api/v1/plantings/#{planting.id}" }
let(:harvests_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/harvests",
"related" => "#{resource_url}/harvests" } }
end
let(:photos_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/photos",
"related" => "#{resource_url}/photos" } }
end
let(:owner_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/owner",
"related" => "#{resource_url}/owner" } }
end
let(:crop_as_json_api) do
{ "links" =>
{ "self" =>
"#{resource_url}/relationships/crop",
"related" => "#{resource_url}/crop" } }
end
let(:garden_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/garden",
"related" => "#{resource_url}/garden" } }
end
let(:attributes) do
{
"slug" => planting.slug,
"planted-at" => "2014-07-30",
"failed" => false,
"finished-at" => nil,
"finished" => false,
"quantity" => 33,
"description" => planting.description,
"crop-name" => planting.crop.name,
"crop-slug" => planting.crop.slug,
"sunniness" => nil,
"planted-from" => nil,
"expected-lifespan" => nil,
"finish-predicted-at" => nil,
"percentage-grown" => nil,
"first-harvest-date" => nil,
"last-harvest-date" => nil,
"thumbnail" => nil,
"location" => planting.garden.location,
"longitude" => planting.garden.longitude,
"latitude" => planting.garden.latitude
}
end
it '#index' do
get('/api/v1/plantings', params: {}, headers:)
expect(subject['data'][0].keys).to eq(planting_encoded_as_json_api.keys)
expect(subject['data'][0]['attributes'].keys.sort!).to eq(planting_encoded_as_json_api['attributes'].keys.sort!)
expect(subject['data']).to include(planting_encoded_as_json_api)
end
it '#show' do
get("/api/v1/plantings/#{planting.id}", params: {}, headers:)
expect(subject['data']['relationships']).to include("garden" => garden_as_json_api)
expect(subject['data']['relationships']).to include("crop" => crop_as_json_api)
expect(subject['data']['relationships']).to include("owner" => owner_as_json_api)
expect(subject['data']['relationships']).to include("harvests" => harvests_as_json_api)
expect(subject['data']['relationships']).to include("photos" => photos_as_json_api)
expect(subject['data']).to eq(planting_encoded_as_json_api)
end
it '#create' do
expect do
post '/api/v1/plantings', params: { 'planting' => { 'description' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#update' do
expect do
post "/api/v1/plantings/#{planting.id}", headers:, params: {
'planting' => { 'description' => 'can i modify this' }
}
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/plantings/#{planting.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
end
describe "by member/owner" do
before :each do
@member1 = planting.owner
@planting2 = create(:planting, owner: create(:owner))
@member2 = @planting2.owner
let!(:planting) { FactoryBot.create(:planting) }
run_test!
end
end
describe "#show" do
it "locates the correct member" do
get "/api/v1/plantings?filter[owner-id]=#{@member1.id}"
expect(JSON.parse(response.body)['data'][0]['id']).to eq(planting.id.to_s)
post 'Creates a planting' do
tags 'Plantings'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :planting, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
},
relationships: {
type: :object,
properties: {
crop: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string }
},
required: ['type', 'id']
}
},
required: ['data']
},
garden: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string }
},
required: ['type', 'id']
}
},
required: ['data']
}
},
required: ['crop', 'garden']
}
},
required: ['type', 'attributes', 'relationships']
}
},
required: ['data']
}
get "/api/v1/plantings?filter[owner-id]=#{@member2.id}"
expect(JSON.parse(response.body)['data'][0]['id']).to eq(@planting2.id.to_s)
response '201', 'created' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:crop) { create(:crop) }
let(:garden) { create(:garden, owner: member) }
let(:planting) { { data: { type: 'plantings', attributes: { description: 'My API planting' }, relationships: { crop: { data: { type: 'crops', id: crop.id } }, garden: { data: { type: 'gardens', id: garden.id } } } } } }
run_test!
end
pending "The below should be identical to the above, but aren't."
response '401', 'unauthorized' do
let(:crop) { create(:crop) }
let(:garden) { create(:garden) }
let(:planting) { { data: { type: 'plantings', attributes: { description: 'My API planting' }, relationships: { crop: { data: { type: 'crops', id: crop.id } }, garden: { data: { type: 'gardens', id: garden.id } } } } } }
run_test!
end
end
end
get "/api/v1/members/#{@member1.id}/plantings"
expect(JSON.parse(response.body)['data'][0]['id']).to eq(planting.id.to_s)
path '/api/v1/plantings/{id}' do
get 'Retrieves a planting' do
tags 'Plantings'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
get "/api/v1/members/#{@member2.id}/plantings"
expect(JSON.parse(response.body)['data'][0]['id']).to eq(@planting2.id.to_s)
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
slug: { type: :string },
'planted-at': { type: :string, format: 'date' },
failed: { type: :boolean },
finished: { type: :boolean },
'finished-at': { type: :string, format: 'date-time', 'x-nullable': true },
quantity: { type: :integer },
description: { type: :string, 'x-nullable': true },
sunniness: { type: :string, 'x-nullable': true },
'planted-from': { type: :string, 'x-nullable': true },
'expected-lifespan': { type: :integer, 'x-nullable': true },
'finish-predicted-at': { type: :string, format: 'date-time', 'x-nullable': true },
'first-harvest-date': { type: :string, format: 'date', 'x-nullable': true },
'last-harvest-date': { type: :string, format: 'date', 'x-nullable': true },
'crop-name': { type: :string },
'crop-slug': { type: :string },
thumbnail: { type: :string, format: :uri, 'x-nullable': true },
location: { type: :string, 'x-nullable': true },
longitude: { type: :number, format: :float, 'x-nullable': true },
latitude: { type: :number, format: :float, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
garden: { '$ref' => '#/components/schemas/relationship' },
crop: { '$ref' => '#/components/schemas/relationship' },
owner: { '$ref' => '#/components/schemas/relationship' },
photos: { '$ref' => '#/components/schemas/relationship' },
harvests: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:planting) { FactoryBot.create(:planting) }
let(:id) { planting.id }
run_test!
end
end
patch 'Updates a planting' do
tags 'Plantings'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
parameter name: :planting, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
}
},
required: ['type', 'id']
}
},
required: ['data']
}
response '200', 'ok' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:planting_to_update) { create(:planting, owner: member) }
let(:id) { planting_to_update.id }
let(:planting) { { data: { type: 'plantings', id: id, attributes: { description: 'An updated planting' } } } }
run_test!
end
response '401', 'unauthorized' do
let(:planting_to_update) { create(:planting) }
let(:id) { planting_to_update.id }
let(:planting) { { data: { type: 'plantings', id: id, attributes: { description: 'An updated planting' } } } }
run_test!
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_planting) { create(:planting) }
let(:id) { other_member_planting.id }
let(:planting) { { data: { type: 'plantings', id: id, attributes: { description: 'An updated planting' } } } }
run_test!
end
end
delete 'Deletes a planting' do
tags 'Plantings'
parameter name: :id, in: :path, type: :string
response '204', 'no content' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:planting_to_delete) { create(:planting, owner: member) }
let(:id) { planting_to_delete.id }
run_test!
end
response '401', 'unauthorized' do
let(:planting_to_delete) { create(:planting) }
let(:id) { planting_to_delete.id }
run_test!
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_planting) { create(:planting) }
let(:id) { other_member_planting.id }
run_test!
end
end
end

View File

@@ -1,81 +1,261 @@
# frozen_string_literal: true
require 'rails_helper'
require 'swagger_helper'
RSpec.describe 'Seeds', type: :request do
subject { JSON.parse response.body }
RSpec.describe 'Seeds API', type: :request do
path '/api/v1/seeds' do
get 'Lists seeds' do
tags 'Seeds'
produces 'application/vnd.api+json'
parameter name: 'filter[crop]', in: :query, type: :string, required: false
parameter name: 'filter[tradable_to]', in: :query, type: :string, required: false
parameter name: 'filter[organic]', in: :query, type: :string, required: false
parameter name: 'filter[gmo]', in: :query, type: :string, required: false
parameter name: 'filter[heirloom]', in: :query, type: :string, required: false
parameter name: 'filter[owner_id]', in: :query, type: :string, required: false
let(:headers) { { 'Accept' => 'application/vnd.api+json' } }
let!(:seed) { FactoryBot.create(:seed) }
let(:seed_encoded_as_json_api) do
{ "id" => seed.id.to_s,
"type" => "seeds",
"links" => { "self" => resource_url },
"attributes" => attributes,
"relationships" => {
"owner" => owner_as_json_api,
"crop" => crop_as_json_api
} }
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :array,
items: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string, 'x-nullable': true },
quantity: { type: :integer, 'x-nullable': true },
'plant-before': { type: :string, format: 'date', 'x-nullable': true },
'tradable-to': { type: :string, 'x-nullable': true },
'days-until-maturity-min': { type: :integer, 'x-nullable': true },
'days-until-maturity-max': { type: :integer, 'x-nullable': true },
organic: { type: :string, 'x-nullable': true },
gmo: { type: :string, 'x-nullable': true },
heirloom: { type: :string, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
crop: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
}
let!(:seed) { FactoryBot.create(:seed) }
run_test!
end
end
post 'Creates a seed' do
tags 'Seeds'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :seed, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
},
relationships: {
type: :object,
properties: {
crop: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string }
},
required: ['type', 'id']
}
},
required: ['data']
}
},
required: ['crop']
}
},
required: ['type', 'attributes', 'relationships']
}
},
required: ['data']
}
response '201', 'created' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:crop) { create(:crop) }
let(:seed) { { data: { type: 'seeds', attributes: { description: 'My API seed' }, relationships: { crop: { data: { type: 'crops', id: crop.id } } } } } }
run_test!
end
response '401', 'unauthorized' do
let(:crop) { create(:crop) }
let(:seed) { { data: { type: 'seeds', attributes: { description: 'My API seed' }, relationships: { crop: { data: { type: 'crops', id: crop.id } } } } } }
run_test!
end
end
end
let(:resource_url) { "http://www.example.com/api/v1/seeds/#{seed.id}" }
path '/api/v1/seeds/{id}' do
get 'Retrieves a seed' do
tags 'Seeds'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
let(:owner_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/owner",
"related" => "#{resource_url}/owner" } }
end
response '200', 'successful' do
schema type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string, 'x-nullable': true },
quantity: { type: :integer, 'x-nullable': true },
'plant-before': { type: :string, format: 'date', 'x-nullable': true },
'tradable-to': { type: :string, 'x-nullable': true },
'days-until-maturity-min': { type: :integer, 'x-nullable': true },
'days-until-maturity-max': { type: :integer, 'x-nullable': true },
organic: { type: :string, 'x-nullable': true },
gmo: { type: :string, 'x-nullable': true },
heirloom: { type: :string, 'x-nullable': true }
}
},
relationships: {
type: :object,
properties: {
owner: { '$ref' => '#/components/schemas/relationship' },
crop: { '$ref' => '#/components/schemas/relationship' }
}
}
}
}
}
let(:seed) { FactoryBot.create(:seed) }
let(:id) { seed.id }
run_test!
end
end
let(:crop_as_json_api) do
{ "links" =>
{ "self" => "#{resource_url}/relationships/crop",
"related" => "#{resource_url}/crop" } }
end
patch 'Updates a seed' do
tags 'Seeds'
consumes 'application/vnd.api+json'
produces 'application/vnd.api+json'
parameter name: :id, in: :path, type: :string
parameter name: :seed, in: :body, schema: {
type: :object,
properties: {
data: {
type: :object,
properties: {
type: { type: :string },
id: { type: :string },
attributes: {
type: :object,
properties: {
description: { type: :string }
}
}
},
required: ['type', 'id']
}
},
required: ['data']
}
let(:attributes) do
{
"description" => seed.description,
"quantity" => seed.quantity,
"plant-before" => "2013-07-15",
"tradable-to" => seed.tradable_to,
"days-until-maturity-min" => seed.days_until_maturity_min,
"days-until-maturity-max" => seed.days_until_maturity_max,
"organic" => seed.organic,
"gmo" => seed.gmo,
"heirloom" => seed.heirloom
}
end
response '200', 'ok' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:seed_to_update) { create(:seed, owner: member) }
let(:id) { seed_to_update.id }
let(:seed) { { data: { type: 'seeds', id: id, attributes: { description: 'An updated seed' } } } }
run_test!
end
describe '#index' do
before { get '/api/v1/seeds', params: {}, headers: }
response '401', 'unauthorized' do
let(:seed_to_update) { create(:seed) }
let(:id) { seed_to_update.id }
let(:seed) { { data: { type: 'seeds', id: id, attributes: { description: 'An updated seed' } } } }
run_test!
end
it { expect(subject['data']).to include(seed_encoded_as_json_api) }
end
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_seed) { create(:seed) }
let(:id) { other_member_seed.id }
let(:seed) { { data: { type: 'seeds', id: id, attributes: { description: 'An updated seed' } } } }
run_test!
end
end
describe '#show' do
before { get "/api/v1/seeds/#{seed.id}", params: {}, headers: }
delete 'Deletes a seed' do
tags 'Seeds'
parameter name: :id, in: :path, type: :string
it { expect(subject['data']['attributes']).to eq(attributes) }
it { expect(subject['data']['relationships']).to include("owner" => owner_as_json_api) }
it { expect(subject['data']['relationships']).to include("crop" => crop_as_json_api) }
it { expect(subject['data']).to eq(seed_encoded_as_json_api) }
end
response '204', 'no content' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:seed_to_delete) { create(:seed, owner: member) }
let(:id) { seed_to_delete.id }
run_test!
end
it '#create' do
expect do
post '/api/v1/seeds', params: { 'seed' => { 'name' => 'can i make this' } }, headers:
end.to raise_error ActionController::RoutingError
end
response '401', 'unauthorized' do
let(:seed_to_delete) { create(:seed) }
let(:id) { seed_to_delete.id }
run_test!
end
it '#update' do
expect do
post "/api/v1/seeds/#{seed.id}", params: { 'seed' => { 'name' => 'can i modify this' } }, headers:
end.to raise_error ActionController::RoutingError
end
it '#delete' do
expect do
delete "/api/v1/seeds/#{seed.id}", params: {}, headers:
end.to raise_error ActionController::RoutingError
response '403', 'forbidden' do
let(:member) { create(:member) }
let(:token) do
member.regenerate_api_token
member.api_token.token
end
let(:Authorization) { "Token token=#{token}" }
let(:other_member_seed) { create(:seed) }
let(:id) { other_member_seed.id }
run_test!
end
end
end
end

View File

@@ -15,13 +15,29 @@ RSpec.configure do |config|
# document below. You can override this behavior by adding a swagger_doc tag to the
# the root example_group in your specs, e.g. describe '...', swagger_doc: 'v2/swagger.json'
config.swagger_docs = {
'v1/swagger.yaml' => {
'v1/swagger.json' => {
openapi: '3.0.1',
info: {
title: 'API V1',
version: 'v1'
},
paths: {}
paths: {},
components: {
schemas: {
relationship: {
type: :object,
properties: {
data: {
type: :object,
properties: {
id: { type: :string },
type: { type: :string }
}
}
}
}
}
}
}
}
@@ -29,5 +45,5 @@ RSpec.configure do |config|
# The swagger_docs configuration option has the filename including format in
# the key, this may want to be changed to avoid putting yaml in json files.
# Defaults to json. Accepts ':json' and ':yaml'.
config.swagger_format = :yaml
config.swagger_format = :json
end

View File

File diff suppressed because it is too large Load Diff