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
5 changes: 5 additions & 0 deletions workspaces/3scale/.changeset/pink-parrots-rush.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@backstage-community/plugin-3scale-backend': patch
---

Added module wiring and contract tests, a contributor guide for local development, and fixed `addLabels: false` config parsing.
78 changes: 78 additions & 0 deletions workspaces/3scale/plugins/3scale-backend/CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Contributing to `@backstage-community/plugin-3scale-backend`

This guide is for **contributors and maintainers** working on the 3scale catalog entity provider. For operator install and production configuration, see [README.md](./README.md).

## Prerequisites

- Node.js **22 or 24** (see `engines` in the workspace `package.json`)
- Yarn (monorepo package manager)
- Clone [backstage/community-plugins](https://github.com/backstage/community-plugins) and work from `workspaces/3scale`

## Default development path

Day-to-day changes do **not** require the full workspace Backstage app (`packages/app`, `packages/backend`). Use the plugin `dev/` harness:

```console
cd workspaces/3scale
yarn install
yarn workspace @backstage-community/plugin-3scale-backend start
```

The harness starts a minimal backend with `@backstage/plugin-catalog-backend` and this module (`dev/index.ts`).

### Configuration

Set environment variables before starting (see [app-config.example.yaml](./app-config.example.yaml)):

| Variable | Purpose |
| ------------------------- | -------------------------------------------------------------------- |
| `THREESCALE_BASE_URL` | 3scale Admin API base URL (e.g. `https://<tenant>-admin.3scale.net`) |
| `THREESCALE_ACCESS_TOKEN` | Admin API access token |

Do not commit real credentials. Use placeholders in tests and local-only env injection.

The workspace root `app-config.yaml` supplies backend infrastructure (database, listen port) when running from the monorepo. Provider-specific keys are documented in `app-config.example.yaml`.

## Validation commands

From `workspaces/3scale`:

```console
yarn workspace @backstage-community/plugin-3scale-backend test
yarn workspace @backstage-community/plugin-3scale-backend lint
yarn tsc
```

All automated tests run under the package `test` script—no extra CI pipeline configuration.

When reviewing dependency updates, read the relevant upstream release notes and changelogs, then run the commands above and any additional manual checks appropriate to what changed.

## Manual smoke checklist

After `yarn workspace @backstage-community/plugin-3scale-backend start` with valid `THREESCALE_*` env vars, expect log lines similar to:

```log
catalog info Discovering ApiEntities from 3scale <baseUrl> type=plugin target=ThreeScaleApiEntityProvider:dev
catalog info Discovered ApiEntity <service-name> type=plugin target=ThreeScaleApiEntityProvider:dev
catalog info Applying the mutation with <N> entities type=plugin target=ThreeScaleApiEntityProvider:dev
```

You can also inspect ingested APIs via the catalog backend API (backend-only, no UI required):

```console
curl -s 'http://localhost:7007/api/catalog/entities?filter=kind=API' | jq .
```

## When to use the full workspace app

The workspace includes `packages/app` and `packages/backend` as an **optional** integration harness. Examples of when it may be useful:

- Catalog **UI** validation (API definition cards, Swagger rendering)
- Workspace Playwright smoke (`packages/app/e2e-tests`)—currently a generic welcome-page check, not 3scale-specific
- Manual validation that mirrors a full Backstage deployment

Do not add a second full Backstage application to this workspace. The plugin `dev/` harness is the documented default for backend work here.

## Workspace app removal note

The plugin `dev/` harness supports backend development and manual smoke described above. `packages/app` and `packages/backend` may be retired in a future cleanup without blocking contributor workflows, provided this guide remains the documented default path.
2 changes: 2 additions & 0 deletions workspaces/3scale/plugins/3scale-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

The 3scale Backstage provider plugin synchronizes the 3scale content into the [Backstage](https://backstage.io/) catalog.

For local development and contributor workflows, see [CONTRIBUTING.md](./CONTRIBUTING.md).

## For administrators

### Installation
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

import {
getProxyConfig,
listApiDocs,
listServices,
} from './ThreeScaleAPIConnector';
import type { APIDocs, Proxy, Services } from './types';

describe('ThreeScaleAPIConnector', () => {
const baseUrl = 'https://example-admin.3scale.net';
const accessToken = 'test-token';

afterEach(() => {
jest.restoreAllMocks();
});

describe('listServices', () => {
it('returns parsed JSON for a successful response', async () => {
const services: Services = { services: [] };
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => services,
} as Response);

await expect(listServices(baseUrl, accessToken, 2, 100)).resolves.toEqual(
services,
);

const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain('/admin/api/services.json');
expect(calledUrl).toContain(`access_token=${accessToken}`);
expect(calledUrl).toContain('page=2');
expect(calledUrl).toContain('size=100');
});

it('throws with statusText for a non-OK response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({}),
} as Response);

await expect(listServices(baseUrl, accessToken, 0, 500)).rejects.toThrow(
'Unauthorized',
);
});
});

describe('listApiDocs', () => {
it('returns parsed JSON for a successful response', async () => {
const apiDocs: APIDocs = { api_docs: [] };
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => apiDocs,
} as Response);

await expect(listApiDocs(baseUrl, accessToken)).resolves.toEqual(apiDocs);

const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain('/admin/api/active_docs.json');
expect(calledUrl).toContain(`access_token=${accessToken}`);
});

it('throws with statusText for a non-OK response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({}),
} as Response);

await expect(listApiDocs(baseUrl, accessToken)).rejects.toThrow(
'Unauthorized',
);
});
});

describe('getProxyConfig', () => {
it('returns parsed JSON for a successful response', async () => {
const proxy: Proxy = {
proxy: {
service_id: 2,
endpoint: 'https://production.example.com',
sandbox_endpoint: 'https://staging.example.com',
} as Proxy['proxy'],
};
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
status: 200,
statusText: 'OK',
json: async () => proxy,
} as Response);

await expect(getProxyConfig(baseUrl, accessToken, 2)).resolves.toEqual(
proxy,
);

const calledUrl = fetchMock.mock.calls[0][0] as string;
expect(calledUrl).toContain('/admin/api/services/2/proxy.json');
expect(calledUrl).toContain(`access_token=${accessToken}`);
});

it('throws with statusText for a non-OK response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: false,
status: 401,
statusText: 'Unauthorized',
json: async () => ({}),
} as Response);

await expect(getProxyConfig(baseUrl, accessToken, 2)).rejects.toThrow(
'Unauthorized',
);
});
});
});
12 changes: 6 additions & 6 deletions workspaces/3scale/plugins/3scale-backend/src/clients/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,8 @@ export interface ServiceService {
mandatory_app_key: boolean;
buyer_can_select_plan: boolean;
buyer_plan_change_permission: string;
created_at: Date;
updated_at: Date;
created_at: string;
updated_at: string;
links: Link[];
}

Expand All @@ -64,8 +64,8 @@ export interface APIDoc {
published: boolean;
skip_swagger_validations: boolean;
body: string;
created_at: Date;
updated_at: Date;
created_at: string;
updated_at: string;
description?: string;
service_id?: number;
}
Expand Down Expand Up @@ -98,8 +98,8 @@ export interface ProxyElement {
sandbox_endpoint: string;
api_test_path: string;
policies_config: PoliciesConfig[];
created_at: Date;
updated_at: Date;
created_at: string;
updated_at: string;
deployment_option: string;
lock_version: number;
links: Link[];
Expand Down
Loading
Loading