Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .projections.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,9 @@
},
"Gemfile": {
"type": "gemfile"
},
"test/fabricators/*_fabricator.rb": {
"type": "fabricators",
"alternate": "app/models/{}.rb"
}
}
2 changes: 1 addition & 1 deletion .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,4 @@ Naming/AccessorMethodName:

Style/SymbolProc:
Exclude:
- "app/serializers/api/v1/employee_serializer.rb"
- "app/serializers/api/**/*"
4 changes: 3 additions & 1 deletion app/controllers/api/apidocs_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ def show
private

def generate_doc
Apidocs::Setup.new(api_version, request.url, controller_classes).run
I18n.with_locale(:en) do
Apidocs::Setup.new(api_version, request.url, controller_classes).run
end
end

def controller_classes
Expand Down
49 changes: 31 additions & 18 deletions app/controllers/api/v1/employees_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,41 @@ module V1
class EmployeesController < JsonapiController
include Scopable

# associations touched by EmployeeSerializer's attribute blocks
EAGER_LOAD_ASSOCIATIONS = {
department: {},
employments: {},
current_employment: { employment_roles_employments: %i[employment_role employment_role_level] }
}.freeze

self.filter_attrs = %i[email ldapname keycloakopenid]

annotate_param :index, :scope, type: 'string',
enum: ['current'],
description: <<~DESC
The query scope:
* current - only employees with a current employment
DESC

annotate_param :index, 'filter[email]', type: 'string',
description: 'Return only the employee with this email address.'
annotate_param :index, 'filter[ldapname]', type: 'string',
description: 'Return only the employee with this LDAP name.'
annotate_param :index, 'filter[keycloakopenid]', type: 'string',
description: 'Return only the employee linked to this ' \
'Keycloak OpenID uid.'
annotate_param :index,
:scope,
type: 'string',
enum: ['current'],
description: <<~DESC
The query scope:
* current - only employees with a current employment
DESC

annotate_param :index,
'filter[email]',
type: 'string',
description: 'Return only the employee with this email address.'

annotate_param :index,
'filter[ldapname]',
type: 'string',
description: 'Return only the employee with this LDAP name.'

annotate_param :index,
'filter[keycloakopenid]',
type: 'string',
description: 'Return only the employee linked to this Keycloak OpenID uid.'

def list_entries
entries = super.includes(:department,
current_employment: {
employment_roles_employments: :employment_role
})
entries = super.includes(EAGER_LOAD_ASSOCIATIONS)
scoped(entries, :current)
end

Expand Down
69 changes: 69 additions & 0 deletions app/controllers/api/v1/orders_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# frozen_string_literal: true

# Copyright (c) 2006-2026, Puzzle ITC GmbH. This file is part of
# PuzzleTime and licensed under the Affero General Public License version 3
# or later. See the COPYING file at the top-level directory or at
# https://github.com/puzzle/puzzletime.

module Api
module V1
class OrdersController < JsonapiController
self.filter_attrs = %i[email ldapname keycloakopenid]

annotate_param :index,
'filter[email]',
type: 'string',
description: 'Return only orders where the employee with this email address ' \
'is responsible or a team member.'

annotate_param :index,
'filter[ldapname]',
type: 'string',
description: 'Return only orders where the employee with this LDAP name ' \
'is responsible or a team member.'

annotate_param :index,
'filter[keycloakopenid]',
type: 'string',
description: 'Return only orders where the employee linked to this Keycloak ' \
'OpenID uid is responsible or a team member.'

private

# eager loading for relationships only needed when side-loaded via ?include=
INCLUDABLE_EAGER_LOADS = {
'responsible' => { responsible: Api::V1::EmployeesController::EAGER_LOAD_ASSOCIATIONS },
'team_members' => { team_members: Api::V1::EmployeesController::EAGER_LOAD_ASSOCIATIONS },
'additional_crm_orders' => :additional_crm_orders
}.freeze

Check warning on line 38 in app/controllers/api/v1/orders_controller.rb

View workflow job for this annotation

GitHub Actions / rubocop

[rubocop] app/controllers/api/v1/orders_controller.rb#L34-L38 <Lint/UselessConstantScoping>

Useless `private` access modifier for constant scope.
Raw output
app/controllers/api/v1/orders_controller.rb:34:7: W: Lint/UselessConstantScoping: Useless `private` access modifier for constant scope.

def list_entries
entries = super.includes(%i[kind status department contract billing_address invoices])
.includes(order_team_members: :employee, team_members: {})
extra_includes = INCLUDABLE_EAGER_LOADS.values_at(*(include_param || [])).compact
extra_includes.present? ? entries.includes(*extra_includes) : entries
end

def filter_by_param_ldapname(entries, _attribute, value)
relevant_entries(entries, Employee.find_by(ldapname: value))
end

def filter_by_param_email(entries, _attribute, value)
relevant_entries(entries, Employee.find_by(email: value))
end

def filter_by_param_keycloakopenid(entries, _attribute, value)
relevant_entries(
entries,
Authentication.find_by(provider: :keycloakopenid, uid: value).employee
)
end

def relevant_entries(entries, employee)
team_order_ids = OrderTeamMember.where(employee_id: employee).select(:order_id)
entries.where(responsible: employee)
.or(entries.where(id: team_order_ids))
end
end
end
end
6 changes: 5 additions & 1 deletion app/controllers/concerns/jsonapi_filterable.rb
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,11 @@ def list_entries
def filter_params
return {} if filter_attrs.blank?

params.fetch(:filter, {}).permit(*filter_attrs).to_h.symbolize_keys
params
.permit(filter: filter_attrs)
.to_h
.fetch(:filter, {})
.symbolize_keys
end
end

Expand Down
9 changes: 5 additions & 4 deletions app/domain/apidocs/annotations/controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,7 @@ module Controller
Param = Struct.new(:name, :type, :description, :required, :enum)

included do
class_attribute :param_annotations,
instance_writer: false,
default: Hash.new { |hash, key| hash[key] = [] }
class_attribute :param_annotations, instance_writer: false, default: {}
end

class_methods do
Expand All @@ -30,7 +28,10 @@ module Controller
# enum - Valid values can be documented with this
#
def annotate_param(action, name, type:, description: nil, required: false, enum: nil)
param_annotations[action.to_sym] << Param.new(name, type, description, required, enum)
action = action.to_sym
param = Param.new(name, type, description, required, enum)
# Reassign per class; a shared class_attribute hash would leak params between controllers.
self.param_annotations = param_annotations.merge(action => param_annotations.fetch(action, []) + [param])
end
end
end
Expand Down
28 changes: 28 additions & 0 deletions app/domain/apidocs/annotations/serializer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,18 @@ module Annotations
module Serializer
extend ActiveSupport::Concern

# Maps ActiveRecord column types to json:api/swagger schema types.
COLUMN_SCHEMA_TYPES = {
integer: { type: :integer },
string: { type: :string },
text: { type: :string },
boolean: { type: :boolean },
float: { type: :number, format: :float },
decimal: { type: :number, format: :double },
date: { type: :string, format: :date },
datetime: { type: :string, format: :'date-time' }
}.freeze

included do
class_attribute :attribute_annotations, default: {}, instance_accessor: false
singleton_class.send(:alias_method, :annotate_attribute, :annotate_attributes)
Expand All @@ -28,6 +40,22 @@ def annotate_attributes(*attributes_list, **spec)
attribute_annotations[attr] = spec
end
end

# Whether the given attribute has an api doc annotation. Attributes
# without one crash the doc generation, so this guards against it.
def annotated?(attr)
attribute_annotations.key?(attr)
end

# Builds an object schema from an ActiveRecord model's columns, so
# attributes serialized via #attributes stay documented without a
# hand-maintained property list that drifts from the schema.
def object_schema(model)
properties = model.columns.to_h do |column|
[column.name.to_sym, COLUMN_SCHEMA_TYPES.fetch(column.type, { type: :string })]
end
{ type: :object, properties: }
end
end
end
end
Expand Down
12 changes: 7 additions & 5 deletions app/domain/apidocs/controller_setup.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ module Apidocs
class ControllerSetup
include Rails.application.routes.url_helpers
include Helper
attr_reader :controller_class, :swagger_spec, :serializer
attr_reader :controller_class, :swagger_spec, :serializer, :component_schema_names

def initialize(controller_class, swagger_spec)
def initialize(controller_class, swagger_spec, component_schema_names)
@controller_class = controller_class
@swagger_spec = swagger_spec
@serializer = controller_class.serializer
@component_schema_names = component_schema_names
end

def run
Expand All @@ -30,21 +31,22 @@ def setup_show_path
end

def show_path
polymorphic_path(namespace << model_name.singular_route_key, id: 1)
polymorphic_path(namespace << model_name.singular_route_key.to_sym, id: 1)
rescue StandardError
nil
end

def index_path
polymorphic_path(namespace << model_name.route_key)
polymorphic_path(namespace << model_name.route_key.to_sym)
rescue StandardError
nil
end

private

# polymorphic_path requires symbols for the route parts.
def namespace
controller_class.name.sub(/(::)?\w+Controller$/, '').underscore.split('/')
controller_class.name.sub(/(::)?\w+Controller$/, '').underscore.split('/').map(&:to_sym)
end
end
end
67 changes: 53 additions & 14 deletions app/domain/apidocs/helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,22 @@ def available_includes(controller = controller_class)
nil
end

# Component schema names that can appear in the top-level `included` array,
# derived from the serializer's includable relationships and filtered to
# those actually documented as components (so the $refs never dangle).
def included_schema_names(controller = controller_class)
controller
.serializer
.relationships_to_serialize
.values
.map { |relationship| relationship.record_type.to_s.camelize }
.uniq
.select { |name| component_schema_names.include?(name) }
.sort
rescue NoMethodError
[]
end

def include_description(controller = controller_class)
relationships = available_includes(controller)
'The following relationships are available: ' \
Expand All @@ -75,10 +91,9 @@ def path_spec(swagger_doc, helper, type)
end

def setup_tags(swagger_doc)
swagger_doc.key :tags, [
'All',
TagsSetup.path_tag(@path)
]
# Tag each operation with its controller's resource name so Swagger UI
# groups endpoints per controller instead of one undifferentiated "All".
swagger_doc.key :tags, [human_name]
end

def parameters(swagger_doc, helper, type)
Expand All @@ -98,7 +113,7 @@ def parameter_id(swagger_doc, helper)
key :in, :path
key :description, "ID of #{helper.human_name} to fetch"
key :required, true
key :type, :integer
schema { key :type, :integer }
end
end

Expand All @@ -108,19 +123,21 @@ def parameter_include(swagger_doc, desc)
key :in, :query
key :description, desc
key :required, false
key :type, :string
schema { key :type, :string }
end
end

def parameter_custom(swagger_doc, type)
controller_class.param_annotations[type].each do |param|
controller_class.param_annotations.fetch(type, []).each do |param|
swagger_doc.parameter do
key :name, param.name
key :in, :query
key :description, param.description
key :required, param.required
key :type, param.type
key :enum, param.enum if param.enum.present?
schema do
key :type, param.type
key :enum, param.enum if param.enum.present?
end
end
end
end
Expand All @@ -130,11 +147,33 @@ def response_schema(swagger_doc, helper, type)
when :index, :show then helper.model_name
when :nested then helper.nested_model_name
end

swagger_doc.schema do
key :type, :array
items do
key :$ref, ref
collection = type.to_sym != :show
clazz = type.to_sym == :nested ? helper.nested_class : controller_class
included = included_schema_names(clazz)

swagger_doc.content(Apidocs::Setup::MEDIA_TYPE) do
schema do
key :type, :object
property :data do
if collection
key :type, :array
items { key :$ref, ref }
else
key :$ref, ref
end
end

if included.present?
property :included do
key :type, :array
key :description, 'Related resources requested via the `include` parameter.'
# OpenAPI 3 `oneOf` over the includable resource schemas (those documented
# as components); heterogeneous arrays could only be generic objects in 2.0.
items do
key(:oneOf, included.map { |name| { '$ref' => "#/components/schemas/#{name}" } })
end
end
end
end
end
end
Expand Down
Loading
Loading