名称: 无头API设计 描述: 用于为无头CMS架构设计内容交付API。涵盖REST和GraphQL API模式、内容预览端点、本地化策略、分页、过滤、缓存头部以及多通道内容交付的API版本化。 允许的工具: 读取、全局搜索、筛选、任务、技能
无头API设计
为无头CMS架构设计内容交付API的指南,实现多通道内容分发。
何时使用此技能
- 设计用于内容交付的REST或GraphQL API
- 实现草稿内容的预览端点
- 向内容API添加本地化/国际化
- 规划分页和过滤策略
- 配置内容的缓存头部
- 版本化内容API
API架构概述
无头CMS API层
┌─────────────────────────────────────────────────────────────┐
│ 内容消费者 │
│ (Blazor, React, Next.js, 移动应用, IoT, 数字标牌) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 内容交付API │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ REST API │ │ GraphQL API │ │ 预览/草稿 API │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 内容服务 │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │
│ │ 内容查询 │ │ 媒体解析器│ │ 本地化服务 │ │
│ └─────────────┘ └─────────────┘ └─────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 内容仓库 │
│ (EF Core + JSON列 + 缓存) │
└─────────────────────────────────────────────────────────────┘
REST API设计
资源端点
GET /api/content # 列出所有内容项
GET /api/content/{id} # 按ID获取内容
GET /api/content/alias/{path} # 按URL路径/别名获取内容
GET /api/content/types/{type} # 按类型列出内容
# 类型特定端点
GET /api/articles # 列出文章
GET /api/articles/{id} # 获取文章
GET /api/pages # 列出页面
GET /api/pages/{id} # 获取页面
# 嵌套资源
GET /api/articles/{id}/comments # 获取文章评论
GET /api/menus/{id}/items # 获取菜单项
查询参数
# 分页
?page=1&pageSize=20 # 偏移分页
?cursor=eyJpZCI6MTIz&limit=20 # 游标分页
# 过滤
?filter[status]=published
?filter[contentType]=Article
?filter[author.id]=abc123
?filter[createdUtc][gte]=2025-01-01
# 排序
?sort=-publishedUtc # 降序
?sort=title # 升序
?sort=category.name,-createdUtc # 多字段
# 字段选择(稀疏字段集)
?fields=id,title,slug,publishedUtc
?fields[article]=title,body
?fields[author]=name,avatar
# 包含相关资源
?include=author,categories
?include=author.profile
响应结构
{
"data": {
"id": "abc123",
"type": "Article",
"attributes": {
"title": "无头CMS入门",
"slug": "getting-started-headless-cms",
"body": "<p>内容在这里...</p>",
"publishedUtc": "2025-01-15T10:30:00Z",
"status": "Published"
},
"parts": {
"titlePart": {
"title": "无头CMS入门"
},
"seoPart": {
"metaTitle": "无头CMS指南",
"metaDescription": "学习如何..."
}
},
"relationships": {
"author": {
"data": { "id": "author456", "type": "Author" }
},
"categories": {
"data": [
{ "id": "cat1", "type": "Category" }
]
}
}
},
"included": [
{
"id": "author456",
"type": "Author",
"attributes": {
"name": "Jane Doe",
"bio": "技术写作者..."
}
}
],
"meta": {
"version": "1.0",
"generatedAt": "2025-01-15T14:22:00Z"
}
}
带分页的集合响应
{
"data": [...],
"meta": {
"totalCount": 156,
"pageSize": 20,
"currentPage": 1,
"totalPages": 8
},
"links": {
"self": "/api/articles?page=1&pageSize=20",
"first": "/api/articles?page=1&pageSize=20",
"prev": null,
"next": "/api/articles?page=2&pageSize=20",
"last": "/api/articles?page=8&pageSize=20"
}
}
GraphQL API设计
模式定义
type Query {
# 单个项目查询
content(id: ID!): ContentItem
contentByPath(path: String!): ContentItem
# 类型特定查询
article(id: ID!): Article
articles(
filter: ArticleFilter
sort: ArticleSort
first: Int
after: String
): ArticleConnection!
page(id: ID!): Page
pages(parentId: ID): [Page!]!
menu(id: ID, name: String): Menu
}
interface ContentItem {
id: ID!
contentType: String!
displayText: String
createdUtc: DateTime!
modifiedUtc: DateTime!
publishedUtc: DateTime
status: ContentStatus!
}
type Article implements ContentItem {
id: ID!
contentType: String!
displayText: String
createdUtc: DateTime!
modifiedUtc: DateTime!
publishedUtc: DateTime
status: ContentStatus!
# 部分
titlePart: TitlePart
autoroutePart: AutoroutePart
seoPart: SeoMetaPart
# 字段
body: String!
featuredImage: MediaField
author: Author
categories: [Category!]!
tags: [String!]!
readTimeMinutes: Int
}
type ArticleConnection {
edges: [ArticleEdge!]!
pageInfo: PageInfo!
totalCount: Int!
}
type ArticleEdge {
node: Article!
cursor: String!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
input ArticleFilter {
status: ContentStatus
categoryId: ID
authorId: ID
tags: [String!]
publishedAfter: DateTime
publishedBefore: DateTime
search: String
}
input ArticleSort {
field: ArticleSortField!
direction: SortDirection!
}
enum ArticleSortField {
TITLE
PUBLISHED_UTC
CREATED_UTC
READ_TIME
}
内容部分作为类型
type TitlePart {
title: String!
displayTitle: String
}
type AutoroutePart {
path: String!
isCustom: Boolean!
}
type SeoMetaPart {
metaTitle: String
metaDescription: String
metaKeywords: String
noIndex: Boolean!
noFollow: Boolean!
}
type MediaField {
paths: [String!]!
urls: [String!]!
alt: String
caption: String
mediaItems: [MediaItem!]!
}
type MediaItem {
id: ID!
url: String!
mimeType: String!
width: Int
height: Int
alt: String
}
预览API
草稿内容端点
# 需要身份验证/预览令牌
GET /api/preview/content/{id}
GET /api/preview/content/{id}?version={versionId}
# 预览令牌在头部
Authorization: Bearer <preview-token>
X-Preview-Mode: true
预览实现
[ApiController]
[Route("api/preview")]
public class PreviewController : ControllerBase
{
private readonly IContentService _contentService;
private readonly IPreviewTokenService _tokenService;
[HttpGet("content/{id}")]
public async Task<ActionResult<ContentItemDto>> GetPreview(
string id,
[FromHeader(Name = "X-Preview-Token")] string? previewToken,
[FromQuery] string? version)
{
// 验证预览令牌
if (!await _tokenService.ValidateTokenAsync(previewToken))
{
return Unauthorized();
}
// 获取草稿或特定版本
var content = version != null
? await _contentService.GetVersionAsync(id, version)
: await _contentService.GetDraftAsync(id);
if (content == null)
{
return NotFound();
}
return Ok(content);
}
}
预览令牌生成
public class PreviewTokenService : IPreviewTokenService
{
public string GenerateToken(string contentId, TimeSpan validity)
{
var payload = new
{
ContentId = contentId,
ExpiresAt = DateTime.UtcNow.Add(validity),
Nonce = Guid.NewGuid().ToString("N")
};
// 使用HMAC或JWT签名
return SignPayload(payload);
}
public async Task<bool> ValidateTokenAsync(string? token)
{
if (string.IsNullOrEmpty(token))
return false;
var payload = VerifyAndDecodeToken(token);
if (payload == null)
return false;
return payload.ExpiresAt > DateTime.UtcNow;
}
}
本地化策略
基于URL的本地化
# 路径前缀(推荐)
GET /api/en/articles
GET /api/fr/articles
GET /api/de-DE/articles
# 查询参数
GET /api/articles?locale=en
GET /api/articles?locale=fr
# Accept-Language头部
Accept-Language: en-US, en;q=0.9, fr;q=0.8
本地化响应结构
{
"data": {
"id": "abc123",
"type": "Article",
"locale": "en-US",
"attributes": {
"title": "入门指南",
"body": "英文内容..."
},
"localizations": {
"available": ["en-US", "fr-FR", "de-DE"],
"links": {
"fr-FR": "/api/fr/articles/abc123",
"de-DE": "/api/de/articles/abc123"
}
}
}
}
回退链
public class LocalizationService
{
public async Task<ContentItem?> GetLocalizedContentAsync(
string id,
string requestedLocale)
{
// 定义回退链
var fallbackChain = GetFallbackChain(requestedLocale);
// 例如:["en-GB", "en", "default"]
foreach (var locale in fallbackChain)
{
var content = await _repository
.GetByIdAndLocaleAsync(id, locale);
if (content != null)
{
return content;
}
}
return null;
}
private List<string> GetFallbackChain(string locale)
{
var chain = new List<string> { locale };
// 添加不带区域的语种
if (locale.Contains('-'))
{
chain.Add(locale.Split('-')[0]);
}
// 添加默认
chain.Add("default");
return chain;
}
}
缓存策略
缓存头部
[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(string id)
{
var content = await _contentService.GetAsync(id);
if (content == null)
{
return NotFound();
}
// 设置缓存头部
Response.Headers["Cache-Control"] = "public, max-age=300"; // 5分钟
Response.Headers["ETag"] = $"\"{content.Version}\"";
Response.Headers["Last-Modified"] = content.ModifiedUtc
.ToString("R"); // RFC 1123格式
return Ok(content);
}
条件GET
[HttpGet("{id}")]
public async Task<ActionResult<ContentItemDto>> Get(
string id,
[FromHeader(Name = "If-None-Match")] string? ifNoneMatch,
[FromHeader(Name = "If-Modified-Since")] string? ifModifiedSince)
{
var content = await _contentService.GetAsync(id);
if (content == null)
{
return NotFound();
}
var etag = $"\"{content.Version}\"";
// 检查ETag
if (ifNoneMatch == etag)
{
return StatusCode(304); // 未修改
}
// 检查最后修改时间
if (DateTime.TryParse(ifModifiedSince, out var modifiedSince))
{
if (content.ModifiedUtc <= modifiedSince)
{
return StatusCode(304); // 未修改
}
}
Response.Headers["ETag"] = etag;
return Ok(content);
}
缓存失效
public class ContentPublishHandler : INotificationHandler<ContentPublishedEvent>
{
private readonly ICacheInvalidationService _cache;
public async Task Handle(ContentPublishedEvent notification,
CancellationToken cancellationToken)
{
// 失效特定内容
await _cache.InvalidateAsync($"content:{notification.ContentId}");
// 失效集合缓存
await _cache.InvalidateByTagAsync($"type:{notification.ContentType}");
// 失效CDN缓存
await _cache.PurgeCdnAsync($"/api/content/{notification.ContentId}");
}
}
API版本化
URL路径版本化
GET /api/v1/content/{id}
GET /api/v2/content/{id}
头部版本化
GET /api/content/{id}
Api-Version: 2.0
实现
// Program.cs
builder.Services.AddApiVersioning(options =>
{
options.DefaultApiVersion = new ApiVersion(1, 0);
options.AssumeDefaultVersionWhenUnspecified = true;
options.ReportApiVersions = true;
options.ApiVersionReader = ApiVersionReader.Combine(
new UrlSegmentApiVersionReader(),
new HeaderApiVersionReader("Api-Version")
);
});
// 控制器
[ApiController]
[ApiVersion("1.0")]
[ApiVersion("2.0")]
[Route("api/v{version:apiVersion}/content")]
public class ContentController : ControllerBase
{
[HttpGet("{id}")]
[MapToApiVersion("1.0")]
public async Task<ActionResult<ContentItemDtoV1>> GetV1(string id)
{
// V1响应形状
}
[HttpGet("{id}")]
[MapToApiVersion("2.0")]
public async Task<ActionResult<ContentItemDtoV2>> GetV2(string id)
{
// V2响应形状,包含重大变化
}
}
安全考虑
API密钥认证
public class ApiKeyAuthenticationHandler : AuthenticationHandler<ApiKeyAuthenticationOptions>
{
protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Api-Key", out var apiKey))
{
return AuthenticateResult.NoResult();
}
var client = await _clientService.ValidateApiKeyAsync(apiKey!);
if (client == null)
{
return AuthenticateResult.Fail("无效API密钥");
}
var claims = new[]
{
new Claim(ClaimTypes.NameIdentifier, client.Id),
new Claim("client_name", client.Name),
new Claim("scope", string.Join(" ", client.Scopes))
};
var identity = new ClaimsIdentity(claims, Scheme.Name);
var principal = new ClaimsPrincipal(identity);
var ticket = new AuthenticationTicket(principal, Scheme.Name);
return AuthenticateResult.Success(ticket);
}
}
速率限制
builder.Services.AddRateLimiter(options =>
{
options.AddPolicy("content-api", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: context.Request.Headers["X-Api-Key"].ToString(),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 1000,
Window = TimeSpan.FromHours(1),
QueueLimit = 0
}));
});
相关技能
content-type-modeling- API响应的内容结构dynamic-schema-design- 灵活API的JSON列存储content-versioning- 版本历史API端点cdn-media-delivery- 媒体API的CDN集成