.claude/skills/nnews-guide/SKILL.md
Guides how to integrate the NNews NuGet package for consuming the NNews CMS API in a .NET 8 project. Use when the user wants to consume articles, categories, tags, images, or AI-powered content generation from the NNews API.
npx skillsauth add landim32/GitNews nnews-guideInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
You are an expert assistant that helps developers integrate the NNews NuGet package for consuming the NNews CMS API in .NET 8 projects.
The user may provide a specific question or context as argument: $ARGUMENTS
If no argument is provided, present a complete overview of the NNews integration.
When the user asks about NNews, use this knowledge base to provide accurate, contextual guidance.
Install: dotnet add package NNews
public class NNewsSetting
{
public string ApiUrl { get; set; } = string.Empty; // NNews API base URL
}
public class ArticleInfo
{
public long ArticleId { get; set; }
public long CategoryId { get; set; }
public long? AuthorId { get; set; }
public string? ImageName { get; set; } // Max 560 chars
public string Title { get; set; } // Required, max 255 chars
public string Content { get; set; } // Required
public int Status { get; set; } // 0=Draft, 1=Published, 2=Archived, 3=Scheduled
public int ContentType { get; set; } = 2; // 1=PlainText, 2=Html, 3=MarkDown
public DateTime DateAt { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public CategoryInfo? Category { get; set; }
public List<TagInfo> Tags { get; set; } = new();
public List<RoleInfo> Roles { get; set; } = new();
}
public class ArticleInsertedInfo
{
public long CategoryId { get; set; } // Required
public long? AuthorId { get; set; }
public string? ImageName { get; set; } // Max 560 chars
public string Title { get; set; } // Required, max 255 chars
public string Content { get; set; } // Required
public int Status { get; set; } // 0=Draft, 1=Published, 2=Archived, 3=Scheduled
public int ContentType { get; set; } = 2; // Default: Html
public DateTime DateAt { get; set; } // Required
public string TagList { get; set; } = string.Empty; // Comma-separated tags
public List<string> Roles { get; set; } = new(); // Role slugs
}
public class ArticleUpdatedInfo
{
public long ArticleId { get; set; }
public long CategoryId { get; set; }
public long? AuthorId { get; set; }
public string? ImageName { get; set; }
public string Title { get; set; }
public string Content { get; set; }
public int Status { get; set; }
public int ContentType { get; set; } = 2;
public DateTime DateAt { get; set; }
public string TagList { get; set; } = string.Empty;
public List<string> Roles { get; set; } = new();
}
public class CategoryInfo
{
public long CategoryId { get; set; }
public long? ParentId { get; set; } // For hierarchical categories
public string Title { get; set; } // Required, max 240 chars
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
public int ArticleCount { get; set; } // Read-only counter
}
public class TagInfo
{
public long TagId { get; set; }
public string Title { get; set; } // Required, max 120 chars
public string? Slug { get; set; } // Max 120 chars
public int ArticleCount { get; set; } // Read-only counter
}
public class RoleInfo
{
public string Slug { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
public class PagedResult<T>
{
public IList<T> Items { get; set; } = new List<T>();
public int Page { get; set; }
public int PageSize { get; set; }
public int TotalCount { get; set; }
public int TotalPages { get; set; }
public bool HasPrevious => Page > 1;
public bool HasNext => Page < TotalPages;
}
// Request for AI content generation
public class AIArticleRequest
{
public long? ArticleId { get; set; }
public string Prompt { get; set; } // Required, 10-2000 chars
public bool GenerateImage { get; set; } = false;
}
// Response from AI generation
public class AIArticleResponse
{
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public long CategoryId { get; set; }
public string TagList { get; set; } = string.Empty;
public string? ImagePrompt { get; set; }
}
// Response from AI article update
public class AIArticleUpdateResponse
{
public long ArticleId { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public long CategoryId { get; set; }
public string TagList { get; set; } = string.Empty;
public string? ImagePrompt { get; set; }
}
// Category summary for AI context
public class AICategorySummary
{
public long CategoryId { get; set; }
public string Title { get; set; } = string.Empty;
public long? ParentId { get; set; }
}
public interface IArticleClient
{
Task<PagedResult<ArticleInfo>> GetAllAsync(long? categoryId = null, int? status = null, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default);
Task<PagedResult<ArticleInfo>> ListByCategoryAsync(long categoryId, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default);
Task<PagedResult<ArticleInfo>> ListByRolesAsync(int page = 1, int pageSize = 10, CancellationToken cancellationToken = default);
Task<PagedResult<ArticleInfo>> ListByTagAsync(string tagSlug, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default);
Task<PagedResult<ArticleInfo>> SearchAsync(string keyword, int page = 1, int pageSize = 10, CancellationToken cancellationToken = default);
Task<ArticleInfo> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<ArticleInfo> CreateAsync(ArticleInsertedInfo article, CancellationToken cancellationToken = default);
Task<ArticleInfo> UpdateAsync(ArticleUpdatedInfo article, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
public interface IArticleAIClient
{
Task<ArticleInfo> CreateWithAIAsync(string prompt, bool generateImage = false, CancellationToken cancellationToken = default);
Task<ArticleInfo> UpdateWithAIAsync(int articleId, string prompt, bool generateImage = false, CancellationToken cancellationToken = default);
}
public interface ICategoryClient
{
Task<IList<CategoryInfo>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IList<CategoryInfo>> ListByParentAsync(long? parentId = null, CancellationToken cancellationToken = default);
Task<CategoryInfo> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<CategoryInfo> CreateAsync(CategoryInfo category, CancellationToken cancellationToken = default);
Task<CategoryInfo> UpdateAsync(CategoryInfo category, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
}
public interface ITagClient
{
Task<IList<TagInfo>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IList<TagInfo>> ListByRolesAsync(CancellationToken cancellationToken = default);
Task<TagInfo> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<TagInfo> CreateAsync(TagInfo tag, CancellationToken cancellationToken = default);
Task<TagInfo> UpdateAsync(TagInfo tag, CancellationToken cancellationToken = default);
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
Task MergeTagsAsync(long sourceTagId, long targetTagId, CancellationToken cancellationToken = default);
}
public interface IImageClient
{
Task<string> UploadImageAsync(IFormFile file, CancellationToken cancellationToken = default);
}
public interface ITenantResolver
{
string TenantId { get; }
string ConnectionString { get; }
string JwtSecret { get; }
}
All endpoints are prefixed by the controller name (e.g., /Article, /Category). Authenticated endpoints require a Bearer JWT token. Tenant is resolved via X-Tenant-Id header or JWT tenant_id claim.
/Article)| Method | Route | Query Params | Body | Auth | Response |
|--------|-------|-------------|------|------|----------|
| GET | /Article | categoryId?, status?, page, pageSize | — | Yes | PagedResult<ArticleInfo> |
| GET | /Article/ListByCategory | categoryId, page, pageSize | — | No | PagedResult<ArticleInfo> |
| GET | /Article/ListByRoles | page, pageSize | — | No | PagedResult<ArticleInfo> |
| GET | /Article/ListByTag | tagSlug, page, pageSize | — | No | PagedResult<ArticleInfo> |
| GET | /Article/Search | keyword, page, pageSize | — | No | PagedResult<ArticleInfo> |
| GET | /Article/{id} | — | — | No | ArticleInfo |
| POST | /Article | — | ArticleInsertedInfo | Yes | ArticleInfo (201) |
| POST | /Article/insertWithAI | — | AIArticleRequest | Yes | ArticleInfo (201) |
| PUT | /Article | — | ArticleUpdatedInfo | Yes | ArticleInfo |
| PUT | /Article/updateWithAI | — | AIArticleRequest | Yes | ArticleInfo |
| DELETE | /Article/{id} | — | — | Yes | 204 No Content |
Status filter values: 0 = Draft, 1 = Published, 2 = Archived, 3 = Scheduled
/Category)| Method | Route | Query Params | Body | Auth | Response |
|--------|-------|-------------|------|------|----------|
| GET | /Category | — | — | Yes | IList<CategoryInfo> |
| GET | /Category/listByParent | roles?, parentId? | — | No | IList<CategoryInfo> |
| GET | /Category/{id} | — | — | No | CategoryInfo |
| POST | /Category | — | CategoryInfo | Yes | CategoryInfo (201) |
| PUT | /Category | — | CategoryInfo | Yes | CategoryInfo |
| DELETE | /Category/{id} | — | — | Yes | 204 No Content |
/Tag)| Method | Route | Query Params | Body | Auth | Response |
|--------|-------|-------------|------|------|----------|
| GET | /Tag | — | — | Yes | IList<TagInfo> |
| GET | /Tag/ListByRoles | — | — | No | IList<TagInfo> |
| GET | /Tag/{id} | — | — | No | TagInfo |
| POST | /Tag | — | TagInfo | Yes | TagInfo (201) |
| PUT | /Tag | — | TagInfo | Yes | TagInfo |
| DELETE | /Tag/{id} | — | — | Yes | 204 No Content |
| POST | /Tag/merge/{sourceTagId}/{targetTagId} | — | — | Yes | 200 OK |
/Image)| Method | Route | Query Params | Body | Auth | Response |
|--------|-------|-------------|------|------|----------|
| POST | /Image/uploadImage | — | IFormFile (multipart, max 100MB) | Yes | string (image URL) |
NNews supports multi-tenancy via the X-Tenant-Id HTTP header.
A DelegatingHandler that automatically adds the X-Tenant-Id header to all outgoing HTTP requests. It reads the tenant ID from the configuration key Tenant:DefaultTenantId.
Implements ITenantResolver. Reads tenant configuration from:
Tenant:DefaultTenantId — the default tenant IDTenants:{TenantId}:ConnectionString — database connection stringTenants:{TenantId}:JwtSecret — JWT secret for the tenantdotnet add package NNews
{
"NNews": {
"ApiUrl": "http://localhost:5007"
},
"Tenant": {
"DefaultTenantId": "my-tenant"
}
}
Docker: use "ApiUrl": "http://nnews-api:80".
using NNews.ACL;
using NNews.ACL.Interfaces;
using NNews.ACL.Handlers;
using NNews.ACL.Services;
using NNews.DTO.Settings;
// Settings
services.Configure<NNewsSetting>(configuration.GetSection("NNews"));
// Tenant resolver
services.AddScoped<ITenantResolver, TenantResolver>();
// Register TenantHeaderHandler for automatic X-Tenant-Id header
services.AddTransient<TenantHeaderHandler>();
// Register HttpClients with TenantHeaderHandler
services.AddHttpClient<IArticleClient, ArticleClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<IArticleAIClient, ArticleAIClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<ICategoryClient, CategoryClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<ITagClient, TagClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<IImageClient, ImageClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
If you only need to read articles (e.g., a public blog frontend), register only the necessary clients:
services.Configure<NNewsSetting>(configuration.GetSection("NNews"));
services.AddTransient<TenantHeaderHandler>();
services.AddHttpClient<IArticleClient, ArticleClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<ICategoryClient, CategoryClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
services.AddHttpClient<ITagClient, TagClient>()
.AddHttpMessageHandler<TenantHeaderHandler>();
public class BlogService
{
private readonly IArticleClient _articleClient;
public BlogService(IArticleClient articleClient)
{
_articleClient = articleClient;
}
public async Task<PagedResult<ArticleInfo>> GetLatestArticles(int page = 1, int pageSize = 12)
{
return await _articleClient.GetAllAsync(page: page, pageSize: pageSize);
}
public async Task<PagedResult<ArticleInfo>> GetPublishedArticles(int page = 1, int pageSize = 12)
{
// Filter by status: 0=Draft, 1=Published, 2=Archived, 3=Scheduled
return await _articleClient.GetAllAsync(status: 1, page: page, pageSize: pageSize);
}
}
public async Task<PagedResult<ArticleInfo>> GetByCategory(long categoryId, int page = 1)
{
return await _articleClient.ListByCategoryAsync(categoryId, page: page, pageSize: 10);
}
public async Task<PagedResult<ArticleInfo>> GetByTag(string tagSlug, int page = 1)
{
return await _articleClient.ListByTagAsync(tagSlug, page: page, pageSize: 10);
}
public async Task<PagedResult<ArticleInfo>> Search(string keyword, int page = 1)
{
return await _articleClient.SearchAsync(keyword, page: page, pageSize: 10);
}
public async Task<ArticleInfo> GetArticle(int id)
{
return await _articleClient.GetByIdAsync(id);
}
public async Task<ArticleInfo> CreateArticle()
{
var article = new ArticleInsertedInfo
{
Title = "My First Article",
Content = "<p>Hello World!</p>",
CategoryId = 1,
Status = 1, // Published
ContentType = 2, // Html
DateAt = DateTime.UtcNow,
TagList = "dotnet,csharp,webapi",
Roles = new List<string> { "admin", "editor" }
};
return await _articleClient.CreateAsync(article);
}
public async Task<ArticleInfo> UpdateArticle(long articleId)
{
var article = new ArticleUpdatedInfo
{
ArticleId = articleId,
Title = "Updated Title",
Content = "<p>Updated content</p>",
CategoryId = 1,
Status = 1,
ContentType = 2,
DateAt = DateTime.UtcNow,
TagList = "dotnet,updated"
};
return await _articleClient.UpdateAsync(article);
}
public async Task DeleteArticle(int articleId)
{
await _articleClient.DeleteAsync(articleId);
}
public async Task<ArticleInfo> CreateWithAI(string prompt, bool withImage = false)
{
return await _articleAIClient.CreateWithAIAsync(prompt, generateImage: withImage);
}
// Example usage:
// var article = await CreateWithAI("Write an article about clean architecture in .NET 8", withImage: true);
public async Task<ArticleInfo> UpdateWithAI(int articleId, string prompt)
{
return await _articleAIClient.UpdateWithAIAsync(articleId, prompt, generateImage: false);
}
public async Task CategoryExamples()
{
// List all categories
var categories = await _categoryClient.GetAllAsync();
// List root categories (no parent)
var rootCategories = await _categoryClient.ListByParentAsync(parentId: null);
// List subcategories
var subCategories = await _categoryClient.ListByParentAsync(parentId: 1);
// Create category
var newCategory = await _categoryClient.CreateAsync(new CategoryInfo
{
Title = "Technology",
ParentId = null // Root category
});
// Create subcategory
var subCategory = await _categoryClient.CreateAsync(new CategoryInfo
{
Title = "Web Development",
ParentId = newCategory.CategoryId
});
// Delete category
await _categoryClient.DeleteAsync((int)newCategory.CategoryId);
}
public async Task TagExamples()
{
// List all tags
var tags = await _tagClient.GetAllAsync();
// List tags filtered by user roles
var roleTags = await _tagClient.ListByRolesAsync();
// Create tag
var newTag = await _tagClient.CreateAsync(new TagInfo { Title = "C#" });
// Merge tags (move all articles from source to target, then delete source)
await _tagClient.MergeTagsAsync(sourceTagId: 5, targetTagId: 2);
// Delete tag
await _tagClient.DeleteAsync((int)newTag.TagId);
}
[HttpPost("upload")]
[Authorize]
public async Task<ActionResult<string>> UploadImage(IFormFile file)
{
if (file == null || file.Length == 0)
return BadRequest("No file uploaded");
var imageUrl = await _imageClient.UploadImageAsync(file);
return Ok(imageUrl);
}
using Microsoft.AspNetCore.Mvc;
using NNews.ACL.Interfaces;
using NNews.DTO;
[Route("api/[controller]")]
[ApiController]
public class BlogController : ControllerBase
{
private readonly IArticleClient _articleClient;
private readonly ICategoryClient _categoryClient;
private readonly ITagClient _tagClient;
public BlogController(
IArticleClient articleClient,
ICategoryClient categoryClient,
ITagClient tagClient)
{
_articleClient = articleClient;
_categoryClient = categoryClient;
_tagClient = tagClient;
}
[HttpGet("articles")]
public async Task<ActionResult<PagedResult<ArticleInfo>>> GetArticles(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 12,
[FromQuery] long? categoryId = null,
[FromQuery] int? status = null)
{
var result = await _articleClient.GetAllAsync(categoryId, status, page, pageSize);
return Ok(result);
}
[HttpGet("articles/{id}")]
public async Task<ActionResult<ArticleInfo>> GetArticle(int id)
{
var article = await _articleClient.GetByIdAsync(id);
return Ok(article);
}
[HttpGet("articles/search")]
public async Task<ActionResult<PagedResult<ArticleInfo>>> Search(
[FromQuery] string keyword,
[FromQuery] int page = 1)
{
var result = await _articleClient.SearchAsync(keyword, page);
return Ok(result);
}
[HttpDelete("articles/{id}")]
public async Task<IActionResult> DeleteArticle(int id)
{
await _articleClient.DeleteAsync(id);
return NoContent();
}
[HttpGet("categories")]
public async Task<ActionResult<IList<CategoryInfo>>> GetCategories()
{
var categories = await _categoryClient.GetAllAsync();
return Ok(categories);
}
[HttpGet("tags")]
public async Task<ActionResult<IList<TagInfo>>> GetTags()
{
var tags = await _tagClient.GetAllAsync();
return Ok(tags);
}
}
| Issue | Cause | Solution |
|-------|-------|----------|
| HTTP 500 on all requests | NNews API unreachable | Verify NNews:ApiUrl in appsettings |
| HTTP 401 Unauthorized | Missing or invalid auth token | Ensure Bearer token is forwarded or endpoint is public |
| Empty PagedResult | Wrong tenant or no data | Check Tenant:DefaultTenantId configuration |
| DI error for IArticleClient | Missing registration | Add services.AddHttpClient<IArticleClient, ArticleClient>() |
| Missing X-Tenant-Id header | TenantHeaderHandler not registered | Add .AddHttpMessageHandler<TenantHeaderHandler>() |
| NNewsSetting.ApiUrl empty | Missing configuration section | Add "NNews": { "ApiUrl": "..." } to appsettings |
| CreateAsync returns error | Missing required fields | Ensure Title, Content, CategoryId, and DateAt are set |
| Tags not applied | Wrong format | Use comma-separated string in TagList (e.g., "tag1,tag2,tag3") |
Tenant:DefaultTenantId and registering TenantHeaderHandlerdocumentation
Generates a comprehensive, standardized README.md for any project. Use when the user wants to create or regenerate a README file following the project's documentation standard.
data-ai
Creates Mermaid diagrams (.mmd) and generates PNG images from them. Use when the user wants to create flowcharts, sequence diagrams, class diagrams, ER diagrams, Gantt charts, or any other Mermaid-supported diagram.
testing
Guides the implementation of FluentValidation validators for DTOs in a .NET 8 project. Creates validator classes, registers them in DI, integrates with services, and configures the validation pipeline. Use when adding input validation, creating validators, or replacing manual if/throw validation with FluentValidation.
development
Guides implementation of Clean Architecture in .NET projects. Covers all layers: DTO, Domain (Models, Services, Enums), Infra.Interfaces (Repository, AppServices), Infra (Context, Repository, AppServices), and Application (DI/Startup). Use when creating entities, services, repositories, or restructuring architecture. Works with any project type (Web, Console, Mobile, Windows).