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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
changeKind: feature
packages:
- "@azure-tools/typespec-client-generator-core"
---

Add linter warning for empty clients (clients with no operations)
13 changes: 12 additions & 1 deletion packages/typespec-client-generator-core/src/clients.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
isSubscriptionId,
updateWithApiVersionInformation,
} from "./internal-utils.js";
import { createDiagnostic } from "./lib.js";
import { createDiagnostic, reportDiagnostic } from "./lib.js";
import { createSdkMethods, getSdkMethodParameter } from "./methods.js";
import { getCrossLanguageDefinitionId } from "./public-utils.js";
import { getSdkBuiltInType, getSdkCredentialParameter, getTypeSpecBuiltInType } from "./types.js";
Expand Down Expand Up @@ -217,6 +217,17 @@ export function createSdkClientType<TServiceOperation extends SdkServiceOperatio
sdkClientType.methods = diagnostics.pipe(
createSdkMethods<TServiceOperation>(context, client, sdkClientType),
);
// Check if the client is empty (has no methods and no children)
// Only emit diagnostic if client.type is defined (client has a source TypeSpec type to attach the diagnostic to)
if (sdkClientType.methods.length === 0 && !sdkClientType.children?.length && client.type) {
reportDiagnostic(context.program, {
code: "empty-client",
target: client.type,
format: {
name: sdkClientType.name,
},
});
}
addDefaultClientParameters(context, sdkClientType);
// update initialization model properties

Expand Down
6 changes: 6 additions & 0 deletions packages/typespec-client-generator-core/src/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,12 @@ export const $lib = createTypeSpecLibrary({
default: paramMessage`Client "${"name"}" is not inside a service namespace. Use @client({service: MyServiceNS})`,
},
},
"empty-client": {
severity: "warning",
messages: {
default: paramMessage`Client "${"name"}" has no operations. Clients should contain at least one operation.`,
},
},
"union-null": {
severity: "warning",
messages: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ describe("@client", () => {
`
@client
@service
@test namespace MyService;
@test namespace MyService {
op test(): void;
}
`,
)
.toEmitDiagnostics([
Expand All @@ -96,7 +98,9 @@ describe("@client", () => {
`
@client({name: "MySDK"})
@service
@test namespace MyService;
@test namespace MyService {
op test(): void;
}
`,
)
.toEmitDiagnostics([
Expand Down Expand Up @@ -474,18 +478,22 @@ describe("@operationGroup", () => {
@service(#{
title: "DeviceUpdateClient",
})
namespace Azure.IoT.DeviceUpdate;
namespace Azure.IoT.DeviceUpdate {
op test(): void;
}
`,
`
@client({name: "DeviceUpdateClient", service: Azure.IoT.DeviceUpdate}, "python")
namespace Customizations;

@operationGroup("java")
interface SubClientOnlyForJava {
op javaOp(): void;
}

@operationGroup("python")
interface SubClientOnlyForPython {
op pythonOp(): void;
}
`,
];
Expand Down Expand Up @@ -514,16 +522,17 @@ describe("@operationGroup", () => {
strictEqual(listOperationGroups(runner.context, client).length, 1);
}

// csharp should have no client
// csharp should have one client (default from service namespace since no explicit @client for csharp)
{
const runner = await createSdkTestRunner({ emitterName: "@azure-tools/typespec-csharp" });
const [_, diagnostics] = await runner.compileAndDiagnoseWithCustomization(
testCode[0],
testCode[1],
);
expectDiagnosticEmpty(diagnostics);
const client = listClients(runner.context);
strictEqual(client.length, 0);
const clients = listClients(runner.context);
strictEqual(clients.length, 1);
strictEqual(listOperationGroups(runner.context, clients[0]).length, 0);
}
});
});
Expand Down Expand Up @@ -1461,3 +1470,153 @@ it("operations under namespace or interface without @client or @operationGroup",
const operationGroup = operationGroups[0];
strictEqual(listOperationsInOperationGroup(runner.context, operationGroup).length, 1);
});

describe("empty client diagnostic", () => {
it("should emit diagnostic for empty namespace client", async () => {
const diagnostics = await runner.diagnose(`
@client
@service
namespace MyService {
}
`);

expectDiagnostics(diagnostics, {
code: "@azure-tools/typespec-client-generator-core/empty-client",
});
});

it("should not emit diagnostic for client with operations", async () => {
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
op test(): void;
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should not emit diagnostic for client with operation groups", async () => {
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
@operationGroup
interface SubClient {
op test(): void;
}
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should emit diagnostic for empty interface client", async () => {
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
op serviceOp(): void;

@client({service: MyService})
interface MyClient {
}
}
`);

expectDiagnostics(diagnostics, {
code: "@azure-tools/typespec-client-generator-core/empty-client",
});
});

it("should not emit diagnostic for interface client with operations", async () => {
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
@route("/service") op serviceOp(): void;

@client({service: MyService})
interface MyClient {
@route("/test") op test(): void;
}
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should not emit diagnostic for target when operation is moved into it via @clientLocation", async () => {
// Target was originally empty but receives an operation via @clientLocation
// Source still has an operation, so no empty-client diagnostic
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
interface Source {
@route("/stay")
op stayOp(): void;

@route("/moved")
@clientLocation(Target)
op movedOp(): void;
}

interface Target {
}
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should not emit diagnostic when @clientLocation moves all operations out of a non-explicit client", async () => {
// When all operations are moved out of a client that wasn't explicitly defined with @client/@operationGroup,
// the client is simply removed (not warned about) since it was auto-generated
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
interface Source {
@route("/moved")
@clientLocation(Target)
op movedOp(): void;
}

interface Target {
@route("/target")
op targetOp(): void;
}
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should not emit diagnostic when @clientLocation moves all operations to root client", async () => {
// Source interface becomes empty and is removed since it wasn't explicitly defined
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
interface Source {
@route("/moved")
@clientLocation(MyService)
op movedOp(): void;
}
}
`);

expectDiagnosticEmpty(diagnostics);
});

it("should not emit diagnostic when @clientLocation moves all operations to new operation group", async () => {
// Source interface becomes empty and is removed since it wasn't explicitly defined
const diagnostics = await runner.diagnose(`
@service
namespace MyService {
interface Source {
@route("/moved")
@clientLocation("NewTarget")
op movedOp(): void;
}
}
`);

expectDiagnosticEmpty(diagnostics);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ it("namespace doesn't end in client", async () => {
`
@client
@service
namespace MyService;
namespace MyService {
op test(): void;
}
`,
)
.toEmitDiagnostics([
Expand All @@ -43,7 +45,9 @@ it("explicit client name doesn't ends with Client", async () => {
`
@client({name: "MySDK"})
@service
namespace MyService;
namespace MyService {
op test(): void;
}
`,
)
.toEmitDiagnostics([
Expand All @@ -60,11 +64,14 @@ it("interface", async () => {
.expect(
`
@service
namespace MyService;
namespace MyService {
op serviceOp(): void;
}

namespace MyCustomizations {
@client({service: MyService})
interface MyInterface {
op test(): void;
};
}
`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ it("no duplicate operation with @clientLocation", async () => {
`
@service
namespace StorageService;

interface StorageTasks {
@clientLocation("StorageTasksReport")
@route("/list")
Expand All @@ -27,6 +27,8 @@ it("no duplicate operation with @clientLocation", async () => {
`,
);

// StorageTasks becomes empty because all operations are moved out via @clientLocation
// Since it wasn't explicitly defined with @client/@operationGroup, it's simply removed (no diagnostic)
expectDiagnosticEmpty(diagnostics);
});

Expand Down Expand Up @@ -128,7 +130,7 @@ it("duplicate operation with @clientLocation to new clients", async () => {
`
@service
namespace Contoso.WidgetManager;

interface A {
@clientLocation("B")
@route("/a")
Expand All @@ -142,6 +144,7 @@ it("duplicate operation with @clientLocation to new clients", async () => {
`,
);

// A becomes empty and is removed since it wasn't explicitly defined with @client/@operationGroup
expectDiagnostics(diagnostics, [
{
code: "@azure-tools/typespec-client-generator-core/duplicate-client-name",
Expand Down
Loading