From d9d9fc73d77a83a57f66d2415733af7ceca1d927 Mon Sep 17 00:00:00 2001 From: Dario Pranjic Date: Mon, 15 Dec 2025 21:26:09 +0100 Subject: [PATCH 1/2] Add slug to namespace projects --- .../mutations/namespaces/projects/create.rb | 1 + .../mutations/namespaces/projects/update.rb | 1 + app/graphql/types/namespace_project_type.rb | 2 ++ app/models/flow.rb | 1 + app/models/namespace_project.rb | 6 ++++ .../namespaces/projects/create_service.rb | 11 +++++++ .../development/06_namespace_projects.rb | 1 + .../20251215200029_add_slug_to_projects.rb | 10 ++++++ db/schema_migrations/20251215200029 | 1 + db/structure.sql | 3 ++ .../mutation/namespacesprojectscreate.md | 1 + .../mutation/namespacesprojectsupdate.md | 1 + docs/graphql/object/namespaceproject.md | 1 + spec/factories/namespace_projects.rb | 1 + .../types/namespace_project_type_spec.rb | 1 + spec/models/flow_spec.rb | 10 +++--- spec/models/namespace_project_spec.rb | 6 ++++ .../projects/create_mutation_spec.rb | 4 +-- .../projects/create_service_spec.rb | 33 +++++++++++++++++++ 19 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20251215200029_add_slug_to_projects.rb create mode 100644 db/schema_migrations/20251215200029 diff --git a/app/graphql/mutations/namespaces/projects/create.rb b/app/graphql/mutations/namespaces/projects/create.rb index 7f7c69cf..03a16e9e 100644 --- a/app/graphql/mutations/namespaces/projects/create.rb +++ b/app/graphql/mutations/namespaces/projects/create.rb @@ -13,6 +13,7 @@ class Create < BaseMutation argument :description, String, required: false, description: 'Description for the new project.' argument :name, String, required: true, description: 'Name for the new project.' + argument :slug, String, required: false, description: 'Slug for the new project.' def resolve(namespace_id:, **params) namespace = SagittariusSchema.object_from_id(namespace_id) diff --git a/app/graphql/mutations/namespaces/projects/update.rb b/app/graphql/mutations/namespaces/projects/update.rb index 9845da61..41344307 100644 --- a/app/graphql/mutations/namespaces/projects/update.rb +++ b/app/graphql/mutations/namespaces/projects/update.rb @@ -15,6 +15,7 @@ class Update < BaseMutation argument :name, String, required: false, description: 'Name for the updated project.' argument :primary_runtime_id, Types::GlobalIdType[::Runtime], required: false, description: 'The primary runtime for the updated project.' + argument :slug, String, required: false, description: 'Slug for the updated project.' def resolve(namespace_project_id:, **params) project = SagittariusSchema.object_from_id(namespace_project_id) diff --git a/app/graphql/types/namespace_project_type.rb b/app/graphql/types/namespace_project_type.rb index 825e6756..c03de707 100644 --- a/app/graphql/types/namespace_project_type.rb +++ b/app/graphql/types/namespace_project_type.rb @@ -10,6 +10,8 @@ class NamespaceProjectType < Types::BaseObject field :name, String, null: false, description: 'Name of the project' + field :slug, String, null: false, description: 'Slug of the project used in URLs to identify flows' + field :runtimes, Types::RuntimeType.connection_type, null: false, description: 'Runtimes assigned to this project' field :roles, Types::NamespaceRoleType.connection_type, null: false, diff --git a/app/models/flow.rb b/app/models/flow.rb index d0a40ca5..d979fb2f 100644 --- a/app/models/flow.rb +++ b/app/models/flow.rb @@ -18,6 +18,7 @@ def to_grpc Tucana::Shared::ValidationFlow.new( flow_id: id, project_id: project.id, + project_slug: project.slug, type: flow_type.identifier, data_types: [], # TODO: when data types are creatable input_type_identifier: input_type&.identifier, diff --git a/app/models/namespace_project.rb b/app/models/namespace_project.rb index ff1acc0f..3db85cbc 100644 --- a/app/models/namespace_project.rb +++ b/app/models/namespace_project.rb @@ -14,6 +14,12 @@ class NamespaceProject < ApplicationRecord source: :role has_many :flows, class_name: 'Flow', inverse_of: :project + validates :slug, presence: true, + length: { minimum: 3, maximum: 50 }, + allow_blank: false, + uniqueness: { case_sensitive: true }, + format: { with: /\A[a-zA-Z0-9_-]+\z/ } + validates :name, presence: true, length: { minimum: 3, maximum: 50 }, allow_blank: false, diff --git a/app/services/namespaces/projects/create_service.rb b/app/services/namespaces/projects/create_service.rb index a378296f..015e7250 100644 --- a/app/services/namespaces/projects/create_service.rb +++ b/app/services/namespaces/projects/create_service.rb @@ -20,6 +20,17 @@ def execute end transactional do |t| + unless params.key?(:slug) + slug = name.parameterize + + tries_left = 5 + while NamespaceProject.exists?(slug: slug) && tries_left.positive? + slug = "#{slug}-#{SecureRandom.hex(4)}" + tries_left -= 1 + end + params[:slug] = slug + end + project = NamespaceProject.create(namespace: namespace, name: name, **params) unless project.persisted? t.rollback_and_return! ServiceResponse.error( diff --git a/db/fixtures/development/06_namespace_projects.rb b/db/fixtures/development/06_namespace_projects.rb index 72001a2a..584c7355 100644 --- a/db/fixtures/development/06_namespace_projects.rb +++ b/db/fixtures/development/06_namespace_projects.rb @@ -3,6 +3,7 @@ NamespaceProject.seed_once :namespace_id, :name do |np| np.namespace_id = Organization.find_by(name: 'Code1').ensure_namespace.id np.name = 'First Project' + np.slug = 'first-project' np.description = 'A sample project for Code1 organization.' np.primary_runtime = Runtime.find_by(name: 'Code1-Runtime') np.runtimes = [Runtime.find_by(name: 'Code1-Runtime')] diff --git a/db/migrate/20251215200029_add_slug_to_projects.rb b/db/migrate/20251215200029_add_slug_to_projects.rb new file mode 100644 index 00000000..836394a4 --- /dev/null +++ b/db/migrate/20251215200029_add_slug_to_projects.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddSlugToProjects < Code0::ZeroTrack::Database::Migration[1.0] + def change + # rubocop:disable Rails/NotNullColumn -- backwards compatibility intentionally ignored + add_column :namespace_projects, :slug, :text, null: false + add_index :namespace_projects, :slug, unique: true + # rubocop:enable Rails/NotNullColumn + end +end diff --git a/db/schema_migrations/20251215200029 b/db/schema_migrations/20251215200029 new file mode 100644 index 00000000..38abc83a --- /dev/null +++ b/db/schema_migrations/20251215200029 @@ -0,0 +1 @@ +10c61a28b25b5ae26834648b6b679e8946f8558faa5e4f18d909398495af654b \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 421fa7d5..aae03fb3 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -486,6 +486,7 @@ CREATE TABLE namespace_projects ( updated_at timestamp with time zone NOT NULL, namespace_id bigint NOT NULL, primary_runtime_id bigint, + slug text NOT NULL, CONSTRAINT check_09e881e641 CHECK ((char_length(name) <= 50)), CONSTRAINT check_a77bf7c685 CHECK ((char_length(description) <= 500)) ); @@ -1175,6 +1176,8 @@ CREATE INDEX index_namespace_projects_on_namespace_id ON namespace_projects USIN CREATE INDEX index_namespace_projects_on_primary_runtime_id ON namespace_projects USING btree (primary_runtime_id); +CREATE UNIQUE INDEX index_namespace_projects_on_slug ON namespace_projects USING btree (slug); + CREATE INDEX index_namespace_role_project_assignments_on_project_id ON namespace_role_project_assignments USING btree (project_id); CREATE UNIQUE INDEX "index_namespace_roles_on_namespace_id_LOWER_name" ON namespace_roles USING btree (namespace_id, lower(name)); diff --git a/docs/graphql/mutation/namespacesprojectscreate.md b/docs/graphql/mutation/namespacesprojectscreate.md index ee7c9cd3..0fa31bdf 100644 --- a/docs/graphql/mutation/namespacesprojectscreate.md +++ b/docs/graphql/mutation/namespacesprojectscreate.md @@ -12,6 +12,7 @@ Creates a new namespace project. | `description` | [`String`](../scalar/string.md) | Description for the new project. | | `name` | [`String!`](../scalar/string.md) | Name for the new project. | | `namespaceId` | [`NamespaceID!`](../scalar/namespaceid.md) | The id of the namespace which this project will belong to | +| `slug` | [`String`](../scalar/string.md) | Slug for the new project. | ## Fields diff --git a/docs/graphql/mutation/namespacesprojectsupdate.md b/docs/graphql/mutation/namespacesprojectsupdate.md index e21483ff..28c191fa 100644 --- a/docs/graphql/mutation/namespacesprojectsupdate.md +++ b/docs/graphql/mutation/namespacesprojectsupdate.md @@ -13,6 +13,7 @@ Updates a namespace project. | `name` | [`String`](../scalar/string.md) | Name for the updated project. | | `namespaceProjectId` | [`NamespaceProjectID!`](../scalar/namespaceprojectid.md) | The id of the namespace project which will be updated | | `primaryRuntimeId` | [`RuntimeID`](../scalar/runtimeid.md) | The primary runtime for the updated project. | +| `slug` | [`String`](../scalar/string.md) | Slug for the updated project. | ## Fields diff --git a/docs/graphql/object/namespaceproject.md b/docs/graphql/object/namespaceproject.md index 62128207..56a47619 100644 --- a/docs/graphql/object/namespaceproject.md +++ b/docs/graphql/object/namespaceproject.md @@ -17,6 +17,7 @@ Represents a namespace project | `primaryRuntime` | [`Runtime`](../object/runtime.md) | The primary runtime for the project | | `roles` | [`NamespaceRoleConnection!`](../object/namespaceroleconnection.md) | Roles assigned to this project | | `runtimes` | [`RuntimeConnection!`](../object/runtimeconnection.md) | Runtimes assigned to this project | +| `slug` | [`String!`](../scalar/string.md) | Slug of the project used in URLs to identify flows | | `updatedAt` | [`Time!`](../scalar/time.md) | Time when this NamespaceProject was last updated | | `userAbilities` | [`NamespaceProjectUserAbilities!`](../object/namespaceprojectuserabilities.md) | Abilities for the current user on this NamespaceProject | diff --git a/spec/factories/namespace_projects.rb b/spec/factories/namespace_projects.rb index 1de0bafd..9c5df1c5 100644 --- a/spec/factories/namespace_projects.rb +++ b/spec/factories/namespace_projects.rb @@ -6,6 +6,7 @@ factory :namespace_project do namespace name { generate(:namespace_project_name) } + slug { name.parameterize } description { '' } end end diff --git a/spec/graphql/types/namespace_project_type_spec.rb b/spec/graphql/types/namespace_project_type_spec.rb index 8c977fdc..53530b7e 100644 --- a/spec/graphql/types/namespace_project_type_spec.rb +++ b/spec/graphql/types/namespace_project_type_spec.rb @@ -7,6 +7,7 @@ %w[ id name + slug description namespace primary_runtime diff --git a/spec/models/flow_spec.rb b/spec/models/flow_spec.rb index 8b72ef5a..3bbd2765 100644 --- a/spec/models/flow_spec.rb +++ b/spec/models/flow_spec.rb @@ -25,11 +25,12 @@ let(:flow) do create( :flow, + flow_type: create(:flow_type, identifier: 'HTTP'), flow_settings: [ create( :flow_setting, - flow_setting_id: 'example_key', - object: { some_key: 'some_value' } + flow_setting_id: 'HTTP_URL', + object: { url: '/some-url' } ) ] ) @@ -63,6 +64,7 @@ { flow_id: flow.id, project_id: flow.project.id, + project_slug: flow.project.slug, type: flow.flow_type.identifier, node_functions: [ { @@ -88,8 +90,8 @@ flow_setting_id: flow.flow_settings.first.flow_setting_id, object: { fields: { - 'some_key' => { - string_value: flow.flow_settings.first.object['some_key'], + 'url' => { + string_value: flow.flow_settings.first.object['url'], }, }, } diff --git a/spec/models/namespace_project_spec.rb b/spec/models/namespace_project_spec.rb index e56c65e5..eeab5092 100644 --- a/spec/models/namespace_project_spec.rb +++ b/spec/models/namespace_project_spec.rb @@ -29,6 +29,12 @@ it { is_expected.to validate_presence_of(:name) } it { is_expected.to validate_uniqueness_of(:name).case_insensitive.scoped_to(:namespace_id) } it { is_expected.to validate_length_of(:name).is_at_most(50) } + it { is_expected.to validate_presence_of(:slug) } + it { is_expected.to validate_uniqueness_of(:slug) } + it { is_expected.to validate_length_of(:slug).is_at_most(50) } + it { is_expected.to allow_value('valid-slug_123').for(:slug) } + it { is_expected.not_to allow_value('invalid slug!').for(:slug) } + it { is_expected.to validate_length_of(:description).is_at_most(500) } it { is_expected.to allow_value(' ').for(:description) } it { is_expected.to allow_value('').for(:description) } diff --git a/spec/requests/graphql/mutation/namespace/projects/create_mutation_spec.rb b/spec/requests/graphql/mutation/namespace/projects/create_mutation_spec.rb index 58f27d5f..a9d1dacb 100644 --- a/spec/requests/graphql/mutation/namespace/projects/create_mutation_spec.rb +++ b/spec/requests/graphql/mutation/namespace/projects/create_mutation_spec.rb @@ -61,7 +61,7 @@ author_id: current_user.id, entity_id: namespace_project.id, entity_type: 'NamespaceProject', - details: { name: input[:name] }, + details: { name: input[:name], slug: namespace_project.slug }, target_id: namespace.id, target_type: 'Namespace' ) @@ -104,7 +104,7 @@ author_id: current_user.id, entity_id: namespace_project.id, entity_type: 'NamespaceProject', - details: { name: input[:name] }, + details: { name: input[:name], slug: namespace_project.slug }, target_id: namespace.id, target_type: 'Namespace' ) diff --git a/spec/services/namespaces/projects/create_service_spec.rb b/spec/services/namespaces/projects/create_service_spec.rb index 047aa801..d9ed155a 100644 --- a/spec/services/namespaces/projects/create_service_spec.rb +++ b/spec/services/namespaces/projects/create_service_spec.rb @@ -62,10 +62,43 @@ entity_type: 'NamespaceProject', details: { name: params[:name], + slug: service_response.payload.slug, }, target_id: namespace.id, target_type: 'Namespace' ) end + + context 'when slug is duplicated' do + let(:params) do + { namespace: namespace, name: generate(:namespace_project_name), slug: 'some-slug' } + end + + before do + create(:namespace_project, slug: 'some-slug') + end + + it 'fails to create project' do + expect(service_response).to be_error + expect(service_response.payload[:error_code]).to eq(:invalid_namespace_project) + end + + context 'when slug is not set' do + let(:params) do + { namespace: namespace, name: generate(:namespace_project_name) } + end + + before do + create(:namespace_project, slug: params[:name].parameterize) + end + + it 'creates project with unique slug' do + expect(service_response).to be_success + expect(service_response.payload.reload).to be_valid + expect(service_response.payload.slug).not_to eq(params[:name].parameterize) + expect(service_response.payload.slug).to match(/^#{params[:name].parameterize}-[a-f0-9]{8}$/) + end + end + end end end From e8e7f1d79ec01b38932e8f7877a03c2f8a244c40 Mon Sep 17 00:00:00 2001 From: Dario Pranjic Date: Tue, 16 Dec 2025 21:37:56 +0100 Subject: [PATCH 2/2] Update tucana --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 332ea2e9..ba5dbf8f 100644 --- a/Gemfile +++ b/Gemfile @@ -79,7 +79,7 @@ gem 'good_job', '~> 4.0' gem 'rotp' gem 'grpc', '~> 1.67' -gem 'tucana', '0.0.47' +gem 'tucana', '0.0.48' gem 'code0-identities', '~> 0.0.2' diff --git a/Gemfile.lock b/Gemfile.lock index 00c70f95..c4f1d4ab 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -376,7 +376,7 @@ GEM thor (1.4.0) timeout (0.4.4) tsort (0.2.0) - tucana (0.0.47) + tucana (0.0.48) grpc (~> 1.64) tzinfo (2.0.6) concurrent-ruby (~> 1.0) @@ -430,7 +430,7 @@ DEPENDENCIES simplecov (~> 0.22.0) simplecov-cobertura (~> 3.0) test-prof (~> 1.0) - tucana (= 0.0.47) + tucana (= 0.0.48) tzinfo-data RUBY VERSION