Skip to content

Commit f43e6b2

Browse files
committed
feat(products): add product API endpoints
Add /api/v1/products CRUD endpoints addressed by code, backed by the product services and query: create, update (code is immutable and not permitted), show, paginated index and soft delete.
1 parent fdd9e5a commit f43e6b2

4 files changed

Lines changed: 261 additions & 1 deletion

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# frozen_string_literal: true
2+
3+
module Api
4+
module V1
5+
class ProductsController < Api::BaseController
6+
def create
7+
result = Products::CreateService.call(
8+
organization: current_organization,
9+
params: input_params.to_h.symbolize_keys
10+
)
11+
12+
if result.success?
13+
render_product(result.product)
14+
else
15+
render_error_response(result)
16+
end
17+
end
18+
19+
def update
20+
product = current_organization.products.find_by(code: params[:code])
21+
result = Products::UpdateService.call(product:, params: update_params.to_h.symbolize_keys)
22+
23+
if result.success?
24+
render_product(result.product)
25+
else
26+
render_error_response(result)
27+
end
28+
end
29+
30+
def destroy
31+
product = current_organization.products.find_by(code: params[:code])
32+
result = Products::DestroyService.call(product:)
33+
34+
if result.success?
35+
render_product(result.product)
36+
else
37+
render_error_response(result)
38+
end
39+
end
40+
41+
def show
42+
product = current_organization.products.find_by(code: params[:code])
43+
44+
return not_found_error(resource: "product") unless product
45+
46+
render_product(product)
47+
end
48+
49+
def index
50+
result = ProductsQuery.call(
51+
organization: current_organization,
52+
pagination: {
53+
page: params[:page],
54+
limit: params[:per_page] || PER_PAGE
55+
}
56+
)
57+
58+
if result.success?
59+
render(
60+
json: ::CollectionSerializer.new(
61+
result.products,
62+
::V1::ProductSerializer,
63+
collection_name: "products",
64+
meta: pagination_metadata(result.products)
65+
)
66+
)
67+
else
68+
render_error_response(result)
69+
end
70+
end
71+
72+
private
73+
74+
def input_params
75+
params.require(:product).permit(
76+
:name,
77+
:code,
78+
:description,
79+
:invoice_display_name
80+
)
81+
end
82+
83+
def update_params
84+
params.require(:product).permit(
85+
:name,
86+
:description,
87+
:invoice_display_name
88+
)
89+
end
90+
91+
def render_product(product)
92+
render(json: ::V1::ProductSerializer.new(product, root_name: "product"))
93+
end
94+
95+
def resource_name
96+
"product"
97+
end
98+
end
99+
end
100+
end

app/models/api_key.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ class ApiKey < ApplicationRecord
77
activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage
88
customer event fee invoice organization order order_form payment payment_receipt payment_request payment_method plan subscription lifetime_usage
99
tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section
10-
billing_entity alert feature security_log quote
10+
billing_entity alert feature security_log quote product
1111
].freeze
1212

1313
MODES = %w[read write].freeze

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@
179179
end
180180
end
181181
end
182+
resources :products, param: :code, code: /.*/, only: %i[index show create update destroy]
182183
resources :taxes, param: :code, code: /.*/
183184
resources :wallet_transactions, only: %i[create show] do
184185
post :payment_url, on: :member
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
RSpec.describe Api::V1::ProductsController do
6+
let(:organization) { create(:organization) }
7+
8+
describe "POST /api/v1/products" do
9+
subject { post_with_token(organization, "/api/v1/products", {product: create_params}) }
10+
11+
let(:create_params) do
12+
{
13+
name: "Cards",
14+
code: "cards",
15+
description: "Card products",
16+
invoice_display_name: "Cards"
17+
}
18+
end
19+
20+
include_examples "requires API permission", "product", "write"
21+
22+
it "creates a product" do
23+
subject
24+
25+
expect(response).to have_http_status(:success)
26+
expect(json[:product][:lago_id]).to be_present
27+
expect(json[:product][:name]).to eq("Cards")
28+
expect(json[:product][:code]).to eq("cards")
29+
expect(json[:product][:description]).to eq("Card products")
30+
expect(json[:product][:invoice_display_name]).to eq("Cards")
31+
end
32+
33+
context "when the code is already used" do
34+
before { create(:product, organization:, code: "cards") }
35+
36+
it "returns a validation error" do
37+
subject
38+
39+
expect(response).to have_http_status(:unprocessable_entity)
40+
end
41+
end
42+
end
43+
44+
describe "PUT /api/v1/products/:code" do
45+
subject { put_with_token(organization, "/api/v1/products/#{product.code}", {product: update_params}) }
46+
47+
let(:product) { create(:product, organization:, name: "Before") }
48+
let(:update_params) { {name: "After"} }
49+
50+
include_examples "requires API permission", "product", "write"
51+
52+
it "updates the product" do
53+
subject
54+
55+
expect(response).to have_http_status(:success)
56+
expect(json[:product][:name]).to eq("After")
57+
expect(json[:product][:code]).to eq(product.code)
58+
end
59+
60+
context "when the product does not exist" do
61+
subject { put_with_token(organization, "/api/v1/products/unknown", {product: update_params}) }
62+
63+
it "returns a not found error" do
64+
subject
65+
66+
expect(response).to be_not_found_error("product")
67+
end
68+
end
69+
end
70+
71+
describe "GET /api/v1/products/:code" do
72+
subject { get_with_token(organization, "/api/v1/products/#{product.code}") }
73+
74+
let(:product) { create(:product, organization:) }
75+
76+
include_examples "requires API permission", "product", "read"
77+
78+
it "returns the product" do
79+
subject
80+
81+
expect(response).to have_http_status(:success)
82+
expect(json[:product][:lago_id]).to eq(product.id)
83+
expect(json[:product][:code]).to eq(product.code)
84+
end
85+
86+
context "when the product does not exist" do
87+
subject { get_with_token(organization, "/api/v1/products/unknown") }
88+
89+
it "returns a not found error" do
90+
subject
91+
92+
expect(response).to be_not_found_error("product")
93+
end
94+
end
95+
96+
context "when the product belongs to another organization" do
97+
let(:product) { create(:product) }
98+
99+
it "returns a not found error" do
100+
subject
101+
102+
expect(response).to be_not_found_error("product")
103+
end
104+
end
105+
end
106+
107+
describe "GET /api/v1/products" do
108+
subject { get_with_token(organization, "/api/v1/products?page=1&per_page=1") }
109+
110+
before { create(:product, organization:) }
111+
112+
include_examples "requires API permission", "product", "read"
113+
114+
it "returns the paginated products" do
115+
create(:product, organization:)
116+
117+
subject
118+
119+
expect(response).to have_http_status(:success)
120+
expect(json[:products].count).to eq(1)
121+
expect(json[:meta][:total_count]).to eq(2)
122+
expect(json[:products].first[:lago_id]).to be_present
123+
end
124+
125+
it "does not return products from other organizations" do
126+
other = create(:product)
127+
128+
subject
129+
130+
expect(json[:products].map { it[:lago_id] }).not_to include(other.id)
131+
end
132+
end
133+
134+
describe "DELETE /api/v1/products/:code" do
135+
subject { delete_with_token(organization, "/api/v1/products/#{product.code}") }
136+
137+
let(:product) { create(:product, organization:) }
138+
139+
include_examples "requires API permission", "product", "write"
140+
141+
it "soft deletes the product" do
142+
subject
143+
144+
expect(response).to have_http_status(:success)
145+
expect(json[:product][:lago_id]).to eq(product.id)
146+
expect(product.reload).to be_discarded
147+
end
148+
149+
context "when the product does not exist" do
150+
subject { delete_with_token(organization, "/api/v1/products/unknown") }
151+
152+
it "returns a not found error" do
153+
subject
154+
155+
expect(response).to be_not_found_error("product")
156+
end
157+
end
158+
end
159+
end

0 commit comments

Comments
 (0)