Commit ddfcf024 authored by Alex Kalderimis's avatar Alex Kalderimis

Introduced parameterized ID types

These are significantly safer and more declarative - they assert and
validate that they can only belong to certain types of objects.
parent 8ab7706d
# frozen_string_literal: true
module Types
class GlobalIDType < GraphQL::Schema::Scalar
description "A wrapper for GraphQL IDs"
class GlobalIDType < BaseScalar
graphql_name 'GlobalID'
description 'A global identifier'
# @param value [GID]
# @return [String]
def self.coerce_result(value, _ctx)
value.to_s
::Gitlab::GlobalId.as_global_id(value).to_s
end
# @param value [String]
# @return [GID]
def self.coerce_input(value, _ctx)
GlobalID.parse(value.to_s)
rescue ArgumentError, TypeError
# Invalid input
nil
gid = GlobalID.parse(value)
raise GraphQL::CoercionError, "#{value.inspect} is not a valid Global ID" if gid.nil?
raise GraphQL::CoercionError, "#{value.inspect} is not a Gitlab Global ID" unless gid.app == GlobalID.app
gid
end
# Construct a restricted type, that can only be inhabited by an ID of
# a given model class.
def self.[](model_class)
@id_types ||= {}
@id_types[model_class] ||= Class.new(self) do
graphql_name "#{model_class.name.gsub(/::/, '')}ID"
description "Identifier of #{model_class.name}"
self.define_singleton_method(:to_s) do
graphql_name
end
self.define_singleton_method(:inspect) do
graphql_name
end
self.define_singleton_method(:coerce_result) do |gid, ctx|
global_id = ::Gitlab::GlobalId.as_global_id(gid, model_name: model_class.name)
if suitable?(global_id)
global_id.to_s
else
raise GraphQL::CoercionError, "Expected a #{model_class.name} ID, got #{global_id}"
end
end
self.define_singleton_method(:suitable?) do |gid|
gid&.model_class&.ancestors&.include?(model_class)
end
self.define_singleton_method(:coerce_input) do |string, ctx|
gid = super(string, ctx)
raise GraphQL::CoercionError, "#{string.inspect} does not represent an instance of #{model_class.name}" unless suitable?(gid)
gid
end
end
end
end
end
......@@ -2,6 +2,8 @@
module Gitlab
module GlobalId
CoerceError = Class.new(ArgumentError)
def self.build(object = nil, model_name: nil, id: nil, params: nil)
if object
model_name ||= object.class.name
......@@ -10,5 +12,20 @@ module Gitlab
::URI::GID.build(app: GlobalID.app, model_name: model_name, model_id: id, params: params)
end
def self.as_global_id(value, model_name: nil)
case value
when GlobalID
value
when URI::GID
GlobalID.new(value)
when Integer
raise CoerceError, 'Cannot coerce Integer' unless model_name.present?
GlobalID.new(::Gitlab::GlobalId.build(model_name: model_name, id: value))
else
raise CoerceError, "Invalid ID. Cannot coerce instances of #{value.class}"
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::GlobalIDType do
let_it_be(:project) { create(:project) }
let(:gid) { project.to_global_id }
let(:foreign_gid) { GlobalID.new(::URI::GID.build(app: 'otherapp', model_name: 'Project', model_id: project.id, params: nil)) }
it 'is has the correct name' do
expect(described_class.to_graphql.name).to eq('GlobalID')
end
describe '.coerce_result' do
it 'can coerce results' do
expect(described_class.coerce_isolated_result(gid)).to eq(gid.to_s)
end
it 'rejects integer IDs' do
expect { described_class.coerce_isolated_result(project.id) }
.to raise_error(ArgumentError)
end
it 'rejects strings' do
expect { described_class.coerce_isolated_result('not a GID') }
.to raise_error(ArgumentError)
end
end
describe '.coerce_input' do
it 'can coerce valid input' do
coerced = described_class.coerce_isolated_input(gid.to_s)
expect(coerced).to eq(gid)
end
it 'handles all valid application GIDs' do
expect { described_class.coerce_isolated_input(build_stubbed(:user).to_global_id.to_s) }
.not_to raise_error
end
it 'rejects invalid input' do
expect { described_class.coerce_isolated_input('not valid') }
.to raise_error(GraphQL::CoercionError)
end
it 'rejects nil' do
expect { described_class.coerce_isolated_input(nil) }
.to raise_error(GraphQL::CoercionError)
end
it 'rejects gids from different apps' do
expect { described_class.coerce_isolated_input(foreign_gid) }
.to raise_error(GraphQL::CoercionError)
end
end
describe 'a parameterized type' do
let(:type) { ::Types::GlobalIDType[::Project] }
it 'is has the correct name' do
expect(type.to_graphql.name).to eq('ProjectID')
end
context 'the GID is appropriate' do
it 'can coerce results' do
expect(type.coerce_isolated_result(gid)).to eq(gid.to_s)
end
it 'can coerce IDs to a GlobalIDType' do
expect(type.coerce_isolated_result(project.id)).to eq(gid.to_s)
end
it 'can coerce valid input' do
expect(type.coerce_isolated_input(gid.to_s)).to eq(gid)
end
end
context 'the GID is not for an appropriate type' do
let(:gid) { build_stubbed(:user).to_global_id }
it 'raises errors when coercing results' do
expect { type.coerce_isolated_result(gid) }.to raise_error(GraphQL::CoercionError)
end
it 'will not coerce invalid input, even if its a valid GID' do
expect { type.coerce_isolated_input(gid.to_s) }
.to raise_error(GraphQL::CoercionError)
end
end
end
describe 'a parameterized type with a namespace' do
let(:type) { ::Types::GlobalIDType[::Ci::Build] }
it 'is has a valid GraphQL identifier for a name' do
expect(type.to_graphql.name).to eq('CiBuildID')
end
end
describe 'compatibility' do
# Simplified schema to test compatibility
def query(doc, vars)
GraphQL::Query.new(schema, document: doc, context: {}, variables: vars)
end
def run_query(gql_query, vars)
query(GraphQL.parse(gql_query), vars).result
end
all_types = [::GraphQL::ID_TYPE, ::Types::GlobalIDType, ::Types::GlobalIDType[::Project]]
shared_examples 'a working query' do
let!(:schema) do
# capture values so they can be closed over
arg_type = argument_type
res_type = result_type
project = Class.new(GraphQL::Schema::Object) do
graphql_name 'Project'
field :name, String, null: false
field :id, res_type, null: false, resolver_method: :global_id
def global_id
object.to_global_id
end
end
Class.new(GraphQL::Schema) do
query(Class.new(GraphQL::Schema::Object) do
graphql_name 'Query'
field :project_by_id, project, null: true do
argument :id, arg_type, required: true
end
def project_by_id(id:)
gid = ::Types::GlobalIDType[::Project].coerce_isolated_input(id)
gid.model_class.find(gid.model_id)
end
end)
end
end
it 'works' do
res = run_query(document, 'projectId' => project.to_global_id.to_s)
expect(res['errors']).to be_blank
expect(res.dig('data', 'project', 'name')).to eq(project.name)
expect(res.dig('data', 'project', 'id')).to eq(project.to_global_id.to_s)
end
end
context 'when the argument is declared as ID' do
let(:document) do
<<-GRAPHQL
query($projectId: ID!){
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
let(:argument_type) { ::GraphQL::ID_TYPE }
where(:result_type) { all_types }
with_them do
it_behaves_like 'a working query'
end
end
context 'when the argument is declared as GlobalID' do
let(:document) do
<<-GRAPHQL
query($projectId: GlobalID!) {
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
let(:argument_type) { ::Types::GlobalIDType }
where(:result_type) { all_types }
with_them do
it_behaves_like 'a working query'
end
end
context 'when the argument is declared as ProjectID' do
let(:document) do
<<-GRAPHQL
query($projectId: ProjectID!) {
project: projectById(id: $projectId) {
name, id
}
}
GRAPHQL
end
let(:argument_type) { ::Types::GlobalIDType[::Project] }
where(:result_type) { all_types }
with_them do
it_behaves_like 'a working query'
end
end
end
end
......@@ -34,4 +34,37 @@ RSpec.describe Gitlab::GlobalId do
expect { described_class.build }.to raise_error(URI::InvalidComponentError)
end
end
describe '.as_global_id' do
let(:project) { build_stubbed(:project) }
it 'is the identify function on GlobalID instances' do
gid = project.to_global_id
expect(described_class.as_global_id(gid)).to eq(gid)
end
it 'wraps URI::GID in GlobalID' do
uri = described_class.build(model_name: 'Foo', id: 1)
expect(described_class.as_global_id(uri)).to eq(GlobalID.new(uri))
end
it 'cannot coerce Integers without a model name' do
expect { described_class.as_global_id(1) }
.to raise_error(described_class::CoerceError, 'Cannot coerce Integer')
end
it 'can coerce Integers with a model name' do
uri = described_class.build(model_name: 'Foo', id: 1)
expect(described_class.as_global_id(1, model_name: 'Foo')).to eq(GlobalID.new(uri))
end
it 'rejects any other value' do
[:symbol, 'string', nil, [], {}, project].each do |value|
expect { described_class.as_global_id(value) }.to raise_error(described_class::CoerceError)
end
end
end
end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment