diff --git a/README.md b/README.md index ae14895..43b9f1d 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,21 @@ If you just want to receive photo data for the most recent Sol for which photos https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/latest_photos?api_key=DEMO_KEY +#### Size parameter: + +Specify the size of the photo you would like to receive. Default value is `large`. + +https://api.nasa.gov/mars-photos/api/v1/rovers/curiosity/photos?api_key=DEMO_KEY&size=small + +| Size Parameter Value | Format | Curiosity | Opportunity | Spirit | Perseverance | +|----------------------|--------|:-----------:|:-----------:|:------:|:------------:| +| `small` | JPEG | ✔ | ✔ | ✔ | ✔ | +| `medium` | JPEG | ✔ | ✔ | ✔ | ✔ | +| `large` | JPEG | ✔ | ✔ | ✔ | ✔ | +| `full` | PNG | N/A | N/A | N/A | ✔ | + +*Note: Only **Perseverance** `small`, `medium`, and `large` size images have guaranteed widths of `300px`, `800px`, and `1200px` respectively. All other dimensions are variable.* + ### Mission Manifest Endpoint A mission manifest is available for each Rover at the `/manifests/`. This manifest will list details of the Rover's mission to help narrow down photo queries to the API. The information in the manifest includes: diff --git a/app/controllers/api/v1/latest_photos_controller.rb b/app/controllers/api/v1/latest_photos_controller.rb index 81048bd..abea7a7 100644 --- a/app/controllers/api/v1/latest_photos_controller.rb +++ b/app/controllers/api/v1/latest_photos_controller.rb @@ -1,26 +1,21 @@ class Api::V1::LatestPhotosController < ApplicationController def index - def index - @rover = Rover.find_by name: params[:rover_id].titleize - if @rover - render json: search_photos, each_serializer: PhotoSerializer, root: :latest_photos - else - render json: { errors: "Invalid Rover Name" }, status: :bad_request - end - end - end + rover = Rover.find_by name: params[:rover_id].titleize - private + if rover + validated_params = params + .permit(:rover_id, :camera, :earth_date, :size, :page, :per_page) + .merge(sol: rover.photos.maximum(:sol)) + photos = helpers.search_photos rover, validated_params - def photo_params - params.permit(:camera, :earth_date, :rover_id).merge(sol: @rover.photos.maximum(:sol)) - end - - def search_photos - photos = @rover.photos.order(:camera_id, :id).search photo_params, @rover - if params[:page] - photos = photos.page(params[:page]).per params[:per_page] + begin + photos = helpers.resize_photos photos, validated_params + render json: photos, each_serializer: PhotoSerializer, root: :latest_photos + rescue PhotoHelper::InvalidSizeParameter => e + render json: { errors: e.message }, status: :bad_request + end + else + render json: { errors: "Invalid Rover Name" }, status: :bad_request end - photos end end diff --git a/app/controllers/api/v1/photos_controller.rb b/app/controllers/api/v1/photos_controller.rb index fac711c..9963c86 100644 --- a/app/controllers/api/v1/photos_controller.rb +++ b/app/controllers/api/v1/photos_controller.rb @@ -1,13 +1,29 @@ class Api::V1::PhotosController < ApplicationController + before_action :photo_params + def show - photo = Photo.find params[:id] - render json: photo, serializer: PhotoSerializer, root: :photo + photo = Photo.find @params[:id] + + begin + resized_photo = helpers.resize_photo photo, @params + render json: resized_photo, serializer: PhotoSerializer, root: :photo + rescue StandardError => e + render json: { errors: e.message }, status: :bad_request + end end def index - rover = Rover.find_by name: params[:rover_id].titleize + rover = Rover.find_by name: @params[:rover_id].titleize + if rover - render json: photos(rover), each_serializer: PhotoSerializer, root: :photos + photos = helpers.search_photos rover, @params + + begin + photos = helpers.resize_photos photos, @params + render json: photos, each_serializer: PhotoSerializer, root: :photos + rescue PhotoHelper::InvalidSizeParameter => e + render json: { errors: e.message }, status: :bad_request + end else render json: { errors: "Invalid Rover Name" }, status: :bad_request end @@ -16,14 +32,6 @@ def index private def photo_params - params.permit :sol, :camera, :earth_date, :rover_id - end - - def photos(rover) - photos = rover.photos.order(:camera_id, :id).search photo_params, rover - if params[:page] - photos = photos.page(params[:page]).per params[:per_page] - end - photos + @params = params.permit :id, :rover_id, :sol, :camera, :earth_date, :size, :page, :per_page end end diff --git a/app/helpers/photo_helper.rb b/app/helpers/photo_helper.rb new file mode 100644 index 0000000..dbfdc8b --- /dev/null +++ b/app/helpers/photo_helper.rb @@ -0,0 +1,108 @@ +module PhotoHelper + + def search_photos rover, params + photos = rover.photos.order(:camera_id, :id).search params, rover + + if params[:page] + photos = photos.page(params[:page]).per params[:per_page] + end + + photos + end + + class InvalidSizeParameter < StandardError + def initialize( + size, + rover_name, + exception_type="custom" + ) + super("Invalid size parameter '#{size}' for #{rover_name.titleize}") + end + end + + def resize_photo photo, params + rover_name = photo.rover.name.downcase + suffix_data = lookup_suffix rover_name, params[:size] + + if !suffix_data.nil? + replace_photo_suffix photo, suffix_data[:old_length], suffix_data[:new] + else + raise InvalidSizeParameter.new params[:size], rover_name + end + end + + def resize_photos photos, params + rover_name = params[:rover_id].downcase + suffix_data = lookup_suffix rover_name, params[:size] + + if suffix_data.present? + replace_each_photo_suffix photos, suffix_data[:old_length], suffix_data[:new] + else + raise InvalidSizeParameter.new params[:size], rover_name + end + end + + private + + def replace_each_photo_suffix photos, old_suffix_length, new_suffix + photos.map do |photo| + replace_photo_suffix photo, old_suffix_length, new_suffix + end + end + + def replace_photo_suffix photo, old_suffix_length, new_suffix + new_photo = photo.clone; + new_photo.img_src = photo.img_src[0, photo.img_src.length - old_suffix_length] + new_suffix + new_photo + end + + def lookup_suffix rover_name, size + suffix_hash = { + :curiosity => { + :original_length => 4, # .jpg or .JPG + :sizes => { + :small => '-thm.jpg', + :medium => '-br.jpg', + :large => '.JPG' + } + }, + :spirit => { + :original_length => 7, # -BR.JPG + :sizes => { + :small => '-THM.JPG', + :medium => '-BR.JPG', + :large => '.JPG' + } + }, + :opportunity => { + :original_length => 7, # -BR.JPG + :sizes => { + :small => '-THM.JPG', + :medium => '-BR.JPG', + :large => '.JPG' + } + }, + :perseverance => { + :original_length => 9, # _1200.jpg + :sizes => { + :small => '_320.jpg', + :medium => '_800.jpg', + :large => '_1200.jpg', + :full => '.png' + } + } + } + + rover = rover_name.downcase.to_sym + size = size.nil? ? :large : size.to_sym + + if suffix_hash.key?(rover) && suffix_hash[rover][:sizes].key?(size) then + { + :old_length => suffix_hash[rover][:original_length], + :new => suffix_hash[rover][:sizes][size] + } + else + nil + end + end +end diff --git a/app/models/photo.rb b/app/models/photo.rb index dbfd173..d112aa5 100644 --- a/app/models/photo.rb +++ b/app/models/photo.rb @@ -11,10 +11,8 @@ class Photo < ActiveRecord::Base def self.search(params, rover) photos = search_by_date params - if params[:camera] - if photos.any? - photos = photos.search_by_camera params, rover - end + if params[:camera] and photos.any? + photos = photos.search_by_camera params, rover end photos end diff --git a/spec/controllers/api/v1/latest_photos_controller_spec.rb b/spec/controllers/api/v1/latest_photos_controller_spec.rb new file mode 100644 index 0000000..a976013 --- /dev/null +++ b/spec/controllers/api/v1/latest_photos_controller_spec.rb @@ -0,0 +1,145 @@ +require 'rails_helper' + +describe Api::V1::LatestPhotosController do + let(:rover) { create(:rover) } + let(:camera) { create(:camera, rover: rover) } + let!(:photo) { create(:photo, rover: rover, camera: camera) } + + suffix_hash = { + 'Curiosity' => { + 'small' => '-thm.jpg', + 'medium' => '-br.jpg', + 'large' => '.JPG' + }, + 'Spirit' => { + 'small' => '-THM.JPG', + 'medium' => '-BR.JPG', + 'large' => '.JPG' + }, + 'Opportunity' => { + 'small' => '-THM.JPG', + 'medium' => '-BR.JPG', + 'large' => '.JPG' + }, + 'Perseverance' => { + 'small' => '_320.jpg', + 'medium' => '_800.jpg', + 'large' => '_1200.jpg', + 'full' => '.png' + } + } + + describe "GET 'index'" do + let!(:latest_photo) { create(:photo, rover: rover, camera: camera) } + before(:each) do + latest_photo.update(sol: 999) + end + + context "with rover_id" do + before(:each) do + get :index, params: { rover_id: rover.name.downcase } + end + + it "returns http 200 success" do + expect(response.status).to eq 200 + end + + it "renders photo data from most recent sol" do + expect(json["latest_photos"].first["sol"]).to eq 999 + end + end + + context "with bad Rover name" do + before(:each) do + get :index, params: { rover_id: "this doesn't exist"} + end + + it "returns http 400 bad request" do + expect(response.status).to eq 400 + end + + it "returns an error message" do + expect(json["errors"]).to eq "Invalid Rover Name" + end + end + + context "with camera query" do + before(:each) do + create_list(:photo, 3, rover: rover, camera: create(:camera, {name: 'TEST', rover: rover}), sol: 9999) + get :index, params: { rover_id: rover.name, camera: 'TEST' } + end + + it "returns http 200 success" do + expect(response.status).to eq 200 + end + + it "renders photo data matching sol and camera" do + expect(json["latest_photos"].length).to eq 3 + expect(json["latest_photos"].first["camera"]["name"]).to eq 'TEST' + end + end + + context "with pagination" do + let(:params) { {rover_id: rover.name.downcase} } + + before(:each) do + create_list(:photo, 35, rover: rover, camera: camera, sol: 9999) + end + + it "returns 25 entries per page when a page param is provided" do + get :index, params: params.merge(page: 1) + + expect(json["latest_photos"].length).to eq 25 + end + + it "returns all entries when no page param is provided" do + get :index, params: params + + expect(json["latest_photos"].length).to eq 35 + end + + it "returns n entries per page when a per_page param is provided" do + get :index, params: params.merge(page: 1, per_page: 30) + + expect(json["latest_photos"].length).to eq 30 + end + + it "returns the remaining entries on the last page of results" do + get :index, params: params.merge(page: 2, per_page: 30) + + expect(json["latest_photos"].length).to eq 5 + end + end + + suffix_hash.each do |rover_id, sizes| + context "with rover_id '#{rover_id}'" do + before(:each) do + rover.update(name: rover_id) + end + + sizes.each do |size, suffix| + context "and size '#{size}'" do + before(:each) do + get :index, params: { rover_id: rover_id, size: size } + end + + it "modifies img_src" do + photo = json['latest_photos'].first + expect(photo['img_src']).to end_with suffix + end + end + end + + context "with invalid size parameter" do + before(:each) do + get :index, params: { rover_id: rover_id, size: "not a size" } + end + + it "returns http 400 bad request" do + expect(response.status).to eq 400 + end + end + end + end + end +end diff --git a/spec/controllers/api/v1/photos_controller_spec.rb b/spec/controllers/api/v1/photos_controller_spec.rb index bffae01..9d4f948 100644 --- a/spec/controllers/api/v1/photos_controller_spec.rb +++ b/spec/controllers/api/v1/photos_controller_spec.rb @@ -5,6 +5,30 @@ let(:camera) { create(:camera, rover: rover) } let!(:photo) { create(:photo, rover: rover, camera: camera) } + suffix_hash = { + 'Curiosity' => { + 'small' => '-thm.jpg', + 'medium' => '-br.jpg', + 'large' => '.JPG' + }, + 'Spirit' => { + 'small' => '-THM.JPG', + 'medium' => '-BR.JPG', + 'large' => '.JPG' + }, + 'Opportunity' => { + 'small' => '-THM.JPG', + 'medium' => '-BR.JPG', + 'large' => '.JPG' + }, + 'Perseverance' => { + 'small' => '_320.jpg', + 'medium' => '_800.jpg', + 'large' => '_1200.jpg', + 'full' => '.png' + } + } + describe "GET 'index'" do context "with no query parameters" do before(:each) do @@ -121,6 +145,41 @@ expect(json["photos"].length).to eq 5 end end + + suffix_hash.each do |rover_id, sizes| + context "with rover_id '#{rover_id}'" do + before(:each) do + rover.update(name: rover_id) + end + + sizes.each do |size, suffix| + context "and size '#{size}'" do + before(:each) do + get :index, params: { rover_id: rover_id, sol: 829, size: size } + end + + it "modifies img_src" do + photo = json['photos'].first + expect(photo['img_src']).to end_with suffix + end + end + end + + context "with invalid size parameter" do + before(:each) do + get :index, params: { rover_id: rover_id, sol: 829, size: "not a size" } + end + + it "returns http 400 bad request" do + expect(response.status).to eq 400 + end + + it "returns an error message" do + expect(json["errors"]).to eq "Invalid size parameter 'not a size' for #{rover_id}" + end + end + end + end end describe "GET 'show'" do @@ -137,5 +196,36 @@ expect(json["photo"]) end end + + suffix_hash.each do |rover_id, sizes| + context "one of #{rover_id}'s photos" do + before(:each) do + rover.update(name: rover_id) + end + + sizes.each do |size, suffix| + context "with size parameter '#{size}'" do + before(:each) do + get :show, params: { id: photo.id, size: size } + end + + it "modifies img_src" do + photo = json['photo'] + expect(photo['img_src']).to end_with suffix + end + end + end + + context "with invalid size parameter" do + before(:each) do + get :show, params: { id: photo.id, size: "not a size" } + end + + it "returns http 400 bad request" do + expect(response.status).to eq 400 + end + end + end + end end end diff --git a/spec/factories.rb b/spec/factories.rb index e55502e..4541fe9 100644 --- a/spec/factories.rb +++ b/spec/factories.rb @@ -14,7 +14,7 @@ factory :photo do sequence :img_src do |n| "https://mars.jpl.nasa.gov/msl-raw-images/proj/" + "msl/redops/ods/surface/sol/00829/opgs/edr/fcam/FRB_471079934EDR_F044" + - "2062FHAZ00323M_.JPG#{n}" + "#{n}2062FHAZ00323M_.JPG" end sol { 829 } rover