From 0acd0045311d22f649e2df45232f055270ff34a1 Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Wed, 12 Aug 2026 14:18:49 +0200 Subject: [PATCH 1/6] Allow current=true filter on GET /v3/droplets to return only current droplets across all apps --- app/fetchers/droplet_list_fetcher.rb | 17 +++++--- app/messages/droplets_list_message.rb | 9 +--- .../fetchers/droplet_list_fetcher_spec.rb | 42 +++++++++++++++++++ .../messages/droplets_list_message_spec.rb | 9 +++- 4 files changed, 61 insertions(+), 16 deletions(-) diff --git a/app/fetchers/droplet_list_fetcher.rb b/app/fetchers/droplet_list_fetcher.rb index 9ff62bbbff6..47265b69450 100644 --- a/app/fetchers/droplet_list_fetcher.rb +++ b/app/fetchers/droplet_list_fetcher.rb @@ -32,14 +32,19 @@ def droplet_dataset(eager_loaded_associations, dataset=DropletModel.dataset) end def filter(message, app, space_guids, dataset) - if message.requested?(:current) && app - dataset = dataset.extension(:null_dataset) - return dataset.nullify unless app.droplet - - dataset = dataset.where(guid: app.droplet_guid) + if message.requested?(:current) + if app + dataset = dataset.extension(:null_dataset) + return dataset.nullify unless app.droplet + + dataset = dataset.where(guid: app.droplet_guid) + else + dataset = dataset.select_all(DropletModel.table_name). + join_table(:inner, AppModel.table_name, { droplet_guid: Sequel[DropletModel.table_name][:guid] }, { table_alias: :apps_current }) + end end - dataset = dataset.where(app_guid: message.app_guids) if message.requested?(:app_guids) + dataset = dataset.where(Sequel[DropletModel.table_name][:app_guid] => message.app_guids) if message.requested?(:app_guids) dataset = dataset.where(state: message.states) if message.requested?(:states) diff --git a/app/messages/droplets_list_message.rb b/app/messages/droplets_list_message.rb index a28da12c316..b5d1de8df6c 100644 --- a/app/messages/droplets_list_message.rb +++ b/app/messages/droplets_list_message.rb @@ -18,9 +18,8 @@ class DropletsListMessage < MetadataListMessage validates :states, array: true, allow_nil: true validates :space_guids, array: true, allow_nil: true validates :organization_guids, array: true, allow_nil: true - validates :current, inclusion: { in: ['true'], message: 'only accepts the value \'true\'' }, allow_nil: true, if: -> { app_guid.present? } + validates :current, inclusion: { in: ['true'], message: 'only accepts the value \'true\'' }, allow_nil: true validate :app_nested_request, if: -> { app_guid.present? } - validate :not_app_nested_request, unless: -> { app_guid.present? } def to_param_hash super(exclude: %i[app_guid package_guid]) @@ -32,12 +31,6 @@ def self.from_params(params) private - def not_app_nested_request - invalid_attributes = [] - invalid_attributes << :current if current - errors.add(:base, "Unknown query parameter(s): '#{invalid_attributes.join("', '")}'") if invalid_attributes.present? - end - def app_nested_request invalid_attributes = [] invalid_attributes << :app_guids if app_guids diff --git a/spec/unit/fetchers/droplet_list_fetcher_spec.rb b/spec/unit/fetchers/droplet_list_fetcher_spec.rb index 5b67d3ede25..ba406d1d741 100644 --- a/spec/unit/fetchers/droplet_list_fetcher_spec.rb +++ b/spec/unit/fetchers/droplet_list_fetcher_spec.rb @@ -125,6 +125,48 @@ module VCAP::CloudController expect(results).to contain_exactly(staged_droplet_for_app1) end end + + context 'filtering by current=true' do + let(:filters) { { current: 'true' } } + + context 'when some apps have a current droplet set' do + before do + app1.update(droplet: staged_droplet_for_app1) + app2.update(droplet: staged_droplet_for_app2) + end + + it 'returns only current droplets' do + results = fetcher.fetch_all(message).all + expect(results).to contain_exactly(staged_droplet_for_app1, staged_droplet_for_app2) + end + + it 'does not return non-current droplets' do + results = fetcher.fetch_all(message).all + expect(results).not_to include(failed_droplet_for_app1) + end + end + + context 'when no apps have a current droplet set' do + it 'returns an empty list' do + results = fetcher.fetch_all(message).all + expect(results).to be_empty + end + end + + context 'when combined with app_guids filter' do + before do + app1.update(droplet: staged_droplet_for_app1) + app2.update(droplet: staged_droplet_for_app2) + end + + let(:filters) { { current: 'true', app_guids: [app1.guid] } } + + it 'returns only the current droplet for the specified app' do + results = fetcher.fetch_all(message).all + expect(results).to contain_exactly(staged_droplet_for_app1) + end + end + end end describe '#fetch_for_spaces' do diff --git a/spec/unit/messages/droplets_list_message_spec.rb b/spec/unit/messages/droplets_list_message_spec.rb index 7968f110a3a..19c39563122 100644 --- a/spec/unit/messages/droplets_list_message_spec.rb +++ b/spec/unit/messages/droplets_list_message_spec.rb @@ -125,10 +125,15 @@ module VCAP::CloudController context 'when the query is not nested under an app' do context 'when the request contains current field' do - it 'is invalid' do + it 'is valid' do message = DropletsListMessage.from_params({ current: 'true' }) + expect(message).to be_valid + end + + it 'validates current must be true' do + message = DropletsListMessage.from_params({ current: 'false' }) expect(message).not_to be_valid - expect(message.errors[:base][0]).to include("Unknown query parameter(s): 'current'") + expect(message.errors[:current]).to include("only accepts the value 'true'") end end end From e24a7fcd2e73c45127b025f84e160f6f7eda048d Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Fri, 14 Aug 2026 15:29:11 +0200 Subject: [PATCH 2/6] Address review comments on current=true filter for GET /v3/droplets --- app/messages/droplets_list_message.rb | 7 +++++ spec/request/droplets_spec.rb | 2 +- .../fetchers/droplet_list_fetcher_spec.rb | 28 +++++++++++++++++++ .../messages/droplets_list_message_spec.rb | 10 +++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/app/messages/droplets_list_message.rb b/app/messages/droplets_list_message.rb index b5d1de8df6c..ad5b2aab9ed 100644 --- a/app/messages/droplets_list_message.rb +++ b/app/messages/droplets_list_message.rb @@ -20,6 +20,7 @@ class DropletsListMessage < MetadataListMessage validates :organization_guids, array: true, allow_nil: true validates :current, inclusion: { in: ['true'], message: 'only accepts the value \'true\'' }, allow_nil: true validate :app_nested_request, if: -> { app_guid.present? } + validate :package_nested_request, if: -> { package_guid.present? } def to_param_hash super(exclude: %i[app_guid package_guid]) @@ -38,5 +39,11 @@ def app_nested_request invalid_attributes << :space_guids if space_guids errors.add(:base, "Unknown query parameter(s): '#{invalid_attributes.join("', '")}'") if invalid_attributes.present? end + + def package_nested_request + invalid_attributes = [] + invalid_attributes << :current if current + errors.add(:base, "Unknown query parameter(s): '#{invalid_attributes.join("', '")}'") if invalid_attributes.present? + end end end diff --git a/spec/request/droplets_spec.rb b/spec/request/droplets_spec.rb index c143e6f1fdb..e927bd15817 100644 --- a/spec/request/droplets_spec.rb +++ b/spec/request/droplets_spec.rb @@ -659,6 +659,7 @@ space_guids app_guids organization_guids + current ] end let(:params) do @@ -668,7 +669,6 @@ order_by: 'updated_at', guids: 'foo,bar', app_guid: app_model.guid, - current: true, package_guid: package_model.guid, states: %w[test foo], label_selector: 'foo,bar', diff --git a/spec/unit/fetchers/droplet_list_fetcher_spec.rb b/spec/unit/fetchers/droplet_list_fetcher_spec.rb index ba406d1d741..0ac9855cb80 100644 --- a/spec/unit/fetchers/droplet_list_fetcher_spec.rb +++ b/spec/unit/fetchers/droplet_list_fetcher_spec.rb @@ -166,6 +166,34 @@ module VCAP::CloudController expect(results).to contain_exactly(staged_droplet_for_app1) end end + + context 'when combined with space_guids filter' do + before do + app1.update(droplet: staged_droplet_for_app1) + app2.update(droplet: staged_droplet_for_app2) + end + + let(:filters) { { current: 'true', space_guids: [app1.space.guid] } } + + it 'returns only current droplets in the specified space' do + results = fetcher.fetch_all(message).all + expect(results).to contain_exactly(staged_droplet_for_app1) + end + end + + context 'when combined with organization_guids filter' do + before do + app1.update(droplet: staged_droplet_for_app1) + app2.update(droplet: staged_droplet_for_app2) + end + + let(:filters) { { current: 'true', organization_guids: [app1.organization.guid] } } + + it 'returns only current droplets in the specified organization' do + results = fetcher.fetch_all(message).all + expect(results).to contain_exactly(staged_droplet_for_app1) + end + end end end diff --git a/spec/unit/messages/droplets_list_message_spec.rb b/spec/unit/messages/droplets_list_message_spec.rb index 19c39563122..1da80ba08fa 100644 --- a/spec/unit/messages/droplets_list_message_spec.rb +++ b/spec/unit/messages/droplets_list_message_spec.rb @@ -138,6 +138,16 @@ module VCAP::CloudController end end + context 'when the query is nested under a package' do + context 'when the request contains current field' do + it 'is invalid' do + message = DropletsListMessage.from_params({ package_guid: 'some-package-guid', current: 'true' }) + expect(message).not_to be_valid + expect(message.errors[:base][0]).to include("Unknown query parameter(s): 'current'") + end + end + end + it 'validates organization_guids is an array' do message = DropletsListMessage.from_params organization_guids: 'tricked you, not an array' expect(message).not_to be_valid From 4ed89d512582f0141938fd69f94058691d21b65c Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Mon, 17 Aug 2026 13:26:33 +0200 Subject: [PATCH 3/6] Add request specs for GET /v3/droplets?current=true --- spec/request/droplets_spec.rb | 44 +++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spec/request/droplets_spec.rb b/spec/request/droplets_spec.rb index e927bd15817..c7a7de55de8 100644 --- a/spec/request/droplets_spec.rb +++ b/spec/request/droplets_spec.rb @@ -941,6 +941,50 @@ returned_guids = parsed_response['resources'].pluck('guid') expect(returned_guids).to contain_exactly(droplet1.guid, droplet2.guid, droplet3.guid) end + + it 'filters by current=true as admin' do + current_droplet_app1 = create(:droplet_model, app: app_model, state: VCAP::CloudController::DropletModel::STAGED_STATE) + current_droplet_app2 = create(:droplet_model, app: app_model2, state: VCAP::CloudController::DropletModel::STAGED_STATE) + current_droplet_app3 = create(:droplet_model, app: app_model3, state: VCAP::CloudController::DropletModel::STAGED_STATE) + app_model.update(droplet: current_droplet_app1) + app_model2.update(droplet: current_droplet_app2) + app_model3.update(droplet: current_droplet_app3) + + get '/v3/droplets?current=true', nil, admin_headers + + expect(last_response.status).to eq(200) + returned_guids = parsed_response['resources'].pluck('guid') + expect(returned_guids).to contain_exactly(current_droplet_app1.guid, current_droplet_app2.guid, current_droplet_app3.guid) + end + + it 'filters by current=true as a non-admin developer, returning only current droplets in readable spaces' do + current_droplet_app1 = create(:droplet_model, app: app_model, state: VCAP::CloudController::DropletModel::STAGED_STATE) + current_droplet_app2 = create(:droplet_model, app: app_model2, state: VCAP::CloudController::DropletModel::STAGED_STATE) + current_droplet_app3 = create(:droplet_model, app: app_model3, state: VCAP::CloudController::DropletModel::STAGED_STATE) + app_model.update(droplet: current_droplet_app1) + app_model2.update(droplet: current_droplet_app2) + app_model3.update(droplet: current_droplet_app3) + + get '/v3/droplets?current=true', nil, developer_headers + + expect(last_response.status).to eq(200) + returned_guids = parsed_response['resources'].pluck('guid') + expect(returned_guids).to contain_exactly(current_droplet_app1.guid, current_droplet_app2.guid) + expect(returned_guids).not_to include(current_droplet_app3.guid) + end + + it 'filters by current=true combined with app_guids' do + current_droplet_app1 = create(:droplet_model, app: app_model, state: VCAP::CloudController::DropletModel::STAGED_STATE) + current_droplet_app2 = create(:droplet_model, app: app_model2, state: VCAP::CloudController::DropletModel::STAGED_STATE) + app_model.update(droplet: current_droplet_app1) + app_model2.update(droplet: current_droplet_app2) + + get "/v3/droplets?current=true&app_guids=#{app_model.guid}", nil, developer_headers + + expect(last_response.status).to eq(200) + returned_guids = parsed_response['resources'].pluck('guid') + expect(returned_guids).to contain_exactly(current_droplet_app1.guid) + end end context 'label_selector' do From 8b287a6eac1efd69be5119b1f2d111ed99b42f33 Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Mon, 17 Aug 2026 15:05:13 +0200 Subject: [PATCH 4/6] Document current query parameter for GET /v3/droplets --- docs/v3/source/includes/resources/droplets/_list.md.erb | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/v3/source/includes/resources/droplets/_list.md.erb b/docs/v3/source/includes/resources/droplets/_list.md.erb index adccc40ef62..04bb72b0a82 100644 --- a/docs/v3/source/includes/resources/droplets/_list.md.erb +++ b/docs/v3/source/includes/resources/droplets/_list.md.erb @@ -33,6 +33,7 @@ Name | Type | Description **guids** | _list of strings_ | Comma-delimited list of droplet guids to filter by **states** | _list of strings_ | Comma-delimited list of droplet states to filter by **app_guids** | _list of strings_ | Comma-delimited list of app guids to filter by +**current** | _boolean_ | If true, only include the current droplet for each app **space_guids** | _list of strings_ | Comma-delimited list of space guids to filter by **organization_guids** | _list of strings_ | Comma-delimited list of organization guids to filter by **page** | _integer_ | Page to display; valid values are integers >= 1 From 6c1edd59b2783ad033e57a1a8d3947f8e0b05302 Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Tue, 18 Aug 2026 20:20:23 +0200 Subject: [PATCH 5/6] Add current parameter to OpenAPI spec for GET /v3/droplets --- docs/openapi/apis/cf/latest/paths/Droplets.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/openapi/apis/cf/latest/paths/Droplets.yaml b/docs/openapi/apis/cf/latest/paths/Droplets.yaml index 6a9870671af..301f900457c 100644 --- a/docs/openapi/apis/cf/latest/paths/Droplets.yaml +++ b/docs/openapi/apis/cf/latest/paths/Droplets.yaml @@ -43,6 +43,12 @@ type: string description: | Comma-delimited list of app GUIDs to filter by + - name: current + in: query + schema: + type: boolean + description: | + If true, only include the current droplet for each app - name: space_guids in: query schema: From df827cb17be8b7de1d8e536c16aaab241b249696 Mon Sep 17 00:00:00 2001 From: Wei Quan Date: Wed, 19 Aug 2026 21:18:58 +0200 Subject: [PATCH 6/6] Fix broken Slack links in docs and skip slack.com in link checker --- docs/v3/gulpfile.js | 2 +- docs/v3/source/includes/introduction/_introduction.md | 2 +- docs/v3/source/includes/upgrade_guide/_header.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/v3/gulpfile.js b/docs/v3/gulpfile.js index 5437c77c52e..686c65f80cc 100644 --- a/docs/v3/gulpfile.js +++ b/docs/v3/gulpfile.js @@ -182,7 +182,7 @@ gulp.task('checkV3docs', gulp.series('build', done => { try { checkPathAndExit('build', { - linksToSkip: ['http://localhost:8001/version/release-candidate'], + linksToSkip: ['http://localhost:8001/version/release-candidate', 'slack.com'], recurse: true, silent: true, markdown: true, diff --git a/docs/v3/source/includes/introduction/_introduction.md b/docs/v3/source/includes/introduction/_introduction.md index e7ba663c3a9..ecc765418a5 100644 --- a/docs/v3/source/includes/introduction/_introduction.md +++ b/docs/v3/source/includes/introduction/_introduction.md @@ -11,7 +11,7 @@ key features: * Changing application source code without stopping the app via deployments ## Getting help -The CAPI team can most easily be reached on our [Slack channel](https://cloudfoundry.slack.com/messages/capi/) for +The CAPI team can most easily be reached on our [Slack channel](https://cloudfoundry.slack.com/archives/C07C04W4Q) for questions and issues regarding the API. To report an issue with the docs or API, please feel free to file a GitHub issue on our API repo, [cloud_controller_ng](https://github.com/cloudfoundry/cloud_controller_ng). diff --git a/docs/v3/source/includes/upgrade_guide/_header.md b/docs/v3/source/includes/upgrade_guide/_header.md index f59e42b4cd9..738ccb6005c 100644 --- a/docs/v3/source/includes/upgrade_guide/_header.md +++ b/docs/v3/source/includes/upgrade_guide/_header.md @@ -5,6 +5,6 @@ This document is intended to help client authors upgrade from Cloud Foundry's V2 When moving to the V3 API, it is important to understand that the V3 API is backed by the same database as the V2 API. Though resources may be presented differently and have different interaction patterns, the internal state of CF is the same across both APIs. If you create an organization using the V3 API, it will be visible to the V2 API, and vice-versa. -If you have questions, need help, or want to chat about the upgrade process, please reach out to us in [Cloud Foundry Slack](https://cloudfoundry.slack.com/messages/C07C04W4Q). +If you have questions, need help, or want to chat about the upgrade process, please reach out to us in [Cloud Foundry Slack](https://cloudfoundry.slack.com/archives/C07C04W4Q).