Skip to content
This repository was archived by the owner on Jun 16, 2026. It is now read-only.
Merged
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
41 changes: 41 additions & 0 deletions claude-specification/project/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,46 @@ cora/
| 发布 | `Release` | `Releases` |
| Webhook | `Webhooks` | - |

### Spec Normalizer(服务注册时声明)

Spec 质量问题通过 `NormalizeRule` 在服务注册时声明式修正,**禁止**在 `command.go` 中为特定服务编写 if/else 补丁。

```go
// internal/registry/builtin.go — 服务注册示例
addBuiltin(r, cfg, builtinDef{
name: "myservice",
specData: assets.MyServiceSpec,
normalize: spec.NormalizeRule{
// 资源别名:tag kebab 形式 → 规范资源名
ResourceAlias: map[string]string{
"repositories": "repos",
},
// Tag 重分配:path 前缀匹配 → 修正 tag
TagReassign: []spec.TagReassignRule{
{PathPrefix: "/repos/{o}/{r}/pulls", Tag: "Pulls"},
},
// GitCode 风格 path-encoded operationId verb 提取
OpIDVerbExtract: true,
// 为无 tag 的操作添加 tag
AddMissingTags: []spec.MissingTagRule{
{PathPrefix: "/createPad", Tag: "pad"},
},
},
})
```

**规则类型**:

| 规则 | 用途 | 何时使用 |
|------|------|---------|
| `ResourceAlias` | tag → 规范资源名映射 | 同一概念不同命名(repositories→repos) |
| `TagReassign` | path 前缀 → 修正 tag | spec 作者错标 tag 时 |
| `OpIDVerbExtract` | 提取 path-encoded opId 的 verb | GitCode 风格 `get_api_v5_*` opId |
| `AddMissingTags` | 为无 tag 操作分配 tag | spec 中部分操作缺少 tag 时 |
| `TagRemap` | 直接重命名 tag | 多词 tag("Pull Requests"→"Pulls") |

**禁止**:在 `command.go` 的 `resourceName()`、`verbName()`、`Build()` 等函数中硬编码特定服务的修正逻辑。

---

## 日志规范(项目特有)
Expand Down Expand Up @@ -240,6 +280,7 @@ go run ./cmd/cora -- <service> <resource> <verb> [flags] # 直接运行
| 日志系统设计 | `spec/logging-design.md` |
| API Token 调研 | `spec/api-token-investigation.md` |
| CLI 设计参考 | `spec/reference-cli-design-patterns.md` |
| Spec Normalizer 设计 | `spec/spec-normalizer-design.md` |

---

Expand Down
74 changes: 58 additions & 16 deletions internal/registry/builtin.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,44 @@ import (
"github.com/opensourceways/cora/internal/spec"
)

var (
// gitcodeNormalize fixes known quality issues in the GitCode OpenAPI spec.
gitcodeNormalize = spec.NormalizeRule{
ResourceAlias: map[string]string{
"repositories": "repos",
"organizations": "orgs",
"pull-requests": "pulls",
},
TagReassign: []spec.TagReassignRule{
{PathPrefix: "/api/v5/repos/{owner}/{repo}/pulls", Tag: "Pulls"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/issues", Tag: "Issues"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/labels", Tag: "Labels"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/branches", Tag: "Branch"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/tags", Tag: "Tag"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/releases", Tag: "Release"},
{PathPrefix: "/api/v5/repos/{owner}/{repo}/milestones", Tag: "Milestone"},
},
OpIDVerbExtract: true,
}

// etherpadNormalize assigns tags to untagged operations.
etherpadNormalize = spec.NormalizeRule{
AddMissingTags: []spec.MissingTagRule{
{PathPrefix: "/appendText", Tag: "pad"},
{PathPrefix: "/copyPad", Tag: "pad"},
{PathPrefix: "/getAttributePool", Tag: "pad"},
{PathPrefix: "/getPadID", Tag: "pad"},
{PathPrefix: "/getRevisionChangeset", Tag: "pad"},
{PathPrefix: "/getSavedRevisionsCount", Tag: "pad"},
{PathPrefix: "/getStats", Tag: "pad"},
{PathPrefix: "/listSavedRevisions", Tag: "pad"},
{PathPrefix: "/movePad", Tag: "pad"},
{PathPrefix: "/restoreRevision", Tag: "pad"},
{PathPrefix: "/saveRevision", Tag: "pad"},
},
}
)

const (
etherpadName = "etherpad"
gitcodeName = "gitcode"
Expand All @@ -31,17 +69,19 @@ func registerBuiltins(r *Registry, cfg *config.Config) {
}

addBuiltin(r, cfg, builtinDef{
name: etherpadName,
specData: assets.EtherpadSpec,
cacheDir: cacheDir,
ttl: ttl,
name: etherpadName,
specData: assets.EtherpadSpec,
cacheDir: cacheDir,
ttl: ttl,
normalize: etherpadNormalize,
})

addBuiltin(r, cfg, builtinDef{
name: gitcodeName,
specData: assets.GitcodeSpec,
cacheDir: cacheDir,
ttl: ttl,
name: gitcodeName,
specData: assets.GitcodeSpec,
cacheDir: cacheDir,
ttl: ttl,
normalize: gitcodeNormalize,
})

addBuiltin(r, cfg, builtinDef{
Expand All @@ -67,10 +107,11 @@ func registerBuiltins(r *Registry, cfg *config.Config) {
}

type builtinDef struct {
name string
specData []byte
cacheDir string
ttl time.Duration
name string
specData []byte
cacheDir string
ttl time.Duration
normalize spec.NormalizeRule
}

func addBuiltin(r *Registry, cfg *config.Config, b builtinDef) {
Expand All @@ -82,10 +123,11 @@ func addBuiltin(r *Registry, cfg *config.Config, b builtinDef) {
}

r.entries[b.name] = &Entry{
Name: b.name,
BaseURL: baseURL,
SpecURL: "", // embedded spec — no remote URL needed
loader: spec.NewEmbeddedLoader(b.name, b.specData, b.cacheDir, b.ttl),
Name: b.name,
BaseURL: baseURL,
SpecURL: "", // embedded spec — no remote URL needed
loader: spec.NewEmbeddedLoader(b.name, b.specData, b.cacheDir, b.ttl),
normalizer: spec.NewNormalizer(b.normalize),
}

// Ensure cfg.Services contains an entry for this built-in so the executor
Expand Down
17 changes: 13 additions & 4 deletions internal/registry/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,9 @@ type Entry struct {
// BaseURL is the API root address from the config.
BaseURL string
// SpecURL is the OpenAPI spec source from the config.
SpecURL string
loader *spec.Loader
SpecURL string
loader *spec.Loader
normalizer *spec.Normalizer // nil = no normalization
}

// Registry maps service names → Entry.
Expand Down Expand Up @@ -113,9 +114,17 @@ func (r *Registry) Entries() []*Entry {
return entries
}

// LoadSpec fetches (or returns cached) OpenAPI spec for the entry.
// LoadSpec fetches (or returns cached) OpenAPI spec for the entry,
// applying any registered normalization rules.
func (e *Entry) LoadSpec(ctx context.Context) (*openapi3.T, error) {
return e.loader.Load(ctx)
raw, err := e.loader.Load(ctx)
if err != nil {
return nil, err
}
if e.normalizer != nil {
return e.normalizer.Normalize(raw), nil
}
return raw, nil
}

// LoadCached reads the spec from the local cache only — no network call.
Expand Down
178 changes: 178 additions & 0 deletions internal/spec/normalizer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
package spec

import (
"sort"
"strings"

"github.com/getkin/kin-openapi/openapi3"
)

// NormalizeRule declares transformations to apply to an OpenAPI spec
// before command generation. All fields are optional; zero value = no-op.
type NormalizeRule struct {
// TagRemap renames a tag. Keys are old names, values are new names.
// e.g. {"Pull Requests" → "Pulls"}.
TagRemap map[string]string

// ResourceAlias maps a kebab-cased tag to a canonical resource name.
// Applied after TagRemap.
// e.g. {"pull-requests" → "pulls", "repositories" → "repos"}.
ResourceAlias map[string]string

// TagReassign moves operations matching a path prefix to a target tag.
// Used when spec authors tag an operation with the wrong parent resource.
// Only the first tag (used as resource name) is reassigned.
// e.g. {PathPrefix: "/repos/{o}/{r}/pulls", Tag: "Pulls"}.
TagReassign []TagReassignRule

// OpIDVerbExtract enables HTTP-method-prefix extraction for path-encoded
// operationIds (e.g. GitCode's get_api_v5_… → "get"/"list").
OpIDVerbExtract bool
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The OpIDVerbExtract field is defined in NormalizeRule and configured for GitCode, but it is never used or referenced anywhere in the normalizer or command builder. According to the design document, this flag is supposed to control the path-encoded operation ID verb extraction in command.go, but command.go still hardcodes this check unconditionally. This results in dead configuration code.


// AddMissingTags assigns a tag to operations that have no tags,
// using the path to infer the appropriate tag.
// e.g. {PathPattern: "/createPad*", Tag: "pad", Method: "GET"}.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The example in the comment references PathPattern and a wildcard (/createPad*), but the MissingTagRule struct actually uses PathPrefix and prefix matching. Update the comment to match the actual struct fields and behavior.

Suggested change
// e.g. {PathPattern: "/createPad*", Tag: "pad", Method: "GET"}.
// e.g. {PathPrefix: "/createPad", Tag: "pad", Method: "GET"}.

AddMissingTags []MissingTagRule
}

// TagReassignRule maps a path prefix to a target tag.
type TagReassignRule struct {
PathPrefix string // e.g. "/api/v5/repos/{owner}/{repo}/pulls"
Tag string // target tag, e.g. "Pulls"
}

// MissingTagRule assigns a tag when an operation has none.
type MissingTagRule struct {
PathPrefix string // empty = match any path
Method string // empty = match any method
Tag string // tag to assign
}

// Normalizer applies NormalizeRule to an OpenAPI spec.
type Normalizer struct {
rules NormalizeRule
}

// NewNormalizer creates a Normalizer for the given rules.
func NewNormalizer(rules NormalizeRule) *Normalizer {
return &Normalizer{rules: rules}
}
Comment on lines +56 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Sorting rules.TagReassign on every call to Normalize is inefficient. Since the rules are static, we can sort the slice once in-place inside NewNormalizer during initialization. This allows applyTagReassign to use n.rules.TagReassign directly without copying and sorting on every normalization pass.

// NewNormalizer creates a Normalizer for the given rules.
func NewNormalizer(rules NormalizeRule) *Normalizer {
	// Sort by path length descending so longer (more specific) prefixes
	// are matched before shorter ones.
	sort.Slice(rules.TagReassign, func(i, j int) bool {
		return len(rules.TagReassign[i].PathPrefix) > len(rules.TagReassign[j].PathPrefix)
	})
	return &Normalizer{rules: rules}
}


// Normalize applies all registered transformations to the spec, returning
// a mutated copy. The builder should always use the normalized spec.
func (n *Normalizer) Normalize(spec *openapi3.T) *openapi3.T {
if spec == nil || spec.Paths == nil {
return spec
}

n.applyTagRemap(spec)
n.applyResourceAlias(spec)
n.applyTagReassign(spec)
n.applyAddMissingTags(spec)

return spec
}

// ── tag remap ───────────────────────────────────────────────────────────

func (n *Normalizer) applyTagRemap(spec *openapi3.T) {
if len(n.rules.TagRemap) == 0 {
return
}
for _, pathItem := range spec.Paths.Map() {
if pathItem == nil {
continue
}
for _, op := range pathItem.Operations() {
if op == nil {
continue
}
for i, tag := range op.Tags {
if newTag, ok := n.rules.TagRemap[tag]; ok {
op.Tags[i] = newTag
}
}
}
}
}

// ── resource alias ──────────────────────────────────────────────────────

func (n *Normalizer) applyResourceAlias(spec *openapi3.T) {
if len(n.rules.ResourceAlias) == 0 {
return
}
for _, pathItem := range spec.Paths.Map() {
if pathItem == nil {
continue
}
for _, op := range pathItem.Operations() {
if op == nil || len(op.Tags) == 0 {
continue
}
kebab := strings.ToLower(strings.ReplaceAll(op.Tags[0], " ", "-"))
if canonical, ok := n.rules.ResourceAlias[kebab]; ok {
op.Tags[0] = canonical
}
}
}
}

// ── tag reassign ────────────────────────────────────────────────────────

func (n *Normalizer) applyTagReassign(spec *openapi3.T) {
if len(n.rules.TagReassign) == 0 {
return
}
// Sort by path length descending so longer (more specific) prefixes
// are matched before shorter ones.
sorted := make([]TagReassignRule, len(n.rules.TagReassign))
copy(sorted, n.rules.TagReassign)
sort.Slice(sorted, func(i, j int) bool {
return len(sorted[i].PathPrefix) > len(sorted[j].PathPrefix)
})

for path, pathItem := range spec.Paths.Map() {
if pathItem == nil {
continue
}
for _, rule := range sorted {
if strings.HasPrefix(path, rule.PathPrefix) {
for _, op := range pathItem.Operations() {
if op != nil && len(op.Tags) > 0 {
op.Tags[0] = rule.Tag
}
}
break // first (longest) match wins
}
}
}
}
Comment on lines +123 to +150

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

With rules.TagReassign pre-sorted in NewNormalizer, we can remove the redundant copy and sort operations from applyTagReassign and iterate over n.rules.TagReassign directly.

func (n *Normalizer) applyTagReassign(spec *openapi3.T) {
	if len(n.rules.TagReassign) == 0 {
		return
	}

	for path, pathItem := range spec.Paths.Map() {
		if pathItem == nil {
			continue
		}
		for _, rule := range n.rules.TagReassign {
			if strings.HasPrefix(path, rule.PathPrefix) {
				for _, op := range pathItem.Operations() {
					if op != nil && len(op.Tags) > 0 {
						op.Tags[0] = rule.Tag
					}
				}
				break // first (longest) match wins
			}
		}
	}
}


// ── add missing tags ────────────────────────────────────────────────────

func (n *Normalizer) applyAddMissingTags(spec *openapi3.T) {
if len(n.rules.AddMissingTags) == 0 {
return
}
for path, pathItem := range spec.Paths.Map() {
if pathItem == nil {
continue
}
for method, op := range pathItem.Operations() {
if op == nil || len(op.Tags) > 0 {
continue
}
for _, rule := range n.rules.AddMissingTags {
if rule.Method != "" && !strings.EqualFold(rule.Method, method) {
continue
}
if rule.PathPrefix != "" && !strings.HasPrefix(path, rule.PathPrefix) {
continue
}
op.Tags = []string{rule.Tag}
break
}
}
}
}
Loading
Loading