Skip to content

Commit 62b33bc

Browse files
authored
Add Azure Functions specification analyzer (#1726)
Parses Azure Functions `function.json` binding files and extracts HTTP-trigger endpoints. The directory name supplies the function identifier when an explicit `route` field is absent. Each binding yields one endpoint per declared HTTP method (or a single `ANY` entry when methods are omitted) and carries the `authLevel` and function name as tags for downstream tooling. Closes #1664
1 parent 1872175 commit 62b33bc

13 files changed

Lines changed: 255 additions & 0 deletions

File tree

docs/content/usage/supported/specification/index.ko.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ sort_by = "weight"
2121
| Apisix | YAML | ☑️ | ☑️ |||| ☑️ ||
2222
| Asyncapi | JSON | ☑️ | ☑️ ||| ☑️ |||
2323
| Asyncapi | YAML | ☑️ | ☑️ ||| ☑️ |||
24+
| Azure Functions | JSON | ☑️ | ☑️ ||||||
2425
| Bruno | BRU | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
2526
| Burp | XML | ☑️ | ☑️ | ☑️ || ☑️ | ☑️ | ☑️ |
2627
| Caido | JSON | ☑️ | ☑️ | ☑️ || ☑️ | ☑️ | ☑️ |

docs/content/usage/supported/specification/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Beyond source code analysis, Noir can parse API and data specification formats s
2121
| Apisix | YAML | ☑️ | ☑️ |||| ☑️ ||
2222
| Asyncapi | JSON | ☑️ | ☑️ ||| ☑️ |||
2323
| Asyncapi | YAML | ☑️ | ☑️ ||| ☑️ |||
24+
| Azure Functions | JSON | ☑️ | ☑️ ||||||
2425
| Bruno | BRU | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ | ☑️ ||
2526
| Burp | XML | ☑️ | ☑️ | ☑️ || ☑️ | ☑️ | ☑️ |
2627
| Caido | JSON | ☑️ | ☑️ | ☑️ || ☑️ | ☑️ | ☑️ |

scripts/generate_supported_docs.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ SPEC_FRIENDLY_NAMES = {
3737
"postman" => "Postman Collection",
3838
"aws_cdk" => "AWS CDK",
3939
"aws_cloudformation" => "AWS SAM / CloudFormation",
40+
"azure_functions" => "Azure Functions",
4041
"cloudflare_wrangler" => "Cloudflare Workers (wrangler)",
4142
"serverless_framework" => "Serverless Framework",
4243
"traefik" => "Traefik Dynamic Config",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"bindings": [
3+
{
4+
"type": "httpTrigger",
5+
"direction": "in",
6+
"name": "req",
7+
"methods": ["post"],
8+
"route": "users",
9+
"authLevel": "function"
10+
}
11+
]
12+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"bindings": [
3+
{
4+
"type": "httpTrigger",
5+
"direction": "in",
6+
"name": "req",
7+
"methods": ["get"],
8+
"route": "users",
9+
"authLevel": "anonymous"
10+
}
11+
]
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
require "../../func_spec.cr"
2+
3+
expected_endpoints = [
4+
Endpoint.new("/users", "GET"),
5+
Endpoint.new("/users", "POST"),
6+
]
7+
8+
FunctionalTester.new("fixtures/specification/azure_functions/", {
9+
:techs => 1,
10+
:endpoints => expected_endpoints.size,
11+
}, expected_endpoints).perform_tests
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
require "../../../spec_helper"
2+
require "../../../../src/models/code_locator"
3+
require "../../../../src/analyzer/analyzers/specification/azure_functions"
4+
5+
private def analyze_azure(content : String, function_dir = "MyFunc")
6+
dir = File.tempname("azure_function")
7+
Dir.mkdir_p(File.join(dir, function_dir))
8+
path = File.join(dir, function_dir, "function.json")
9+
File.write(path, content)
10+
locator = CodeLocator.instance
11+
locator.clear "azure-functions-spec"
12+
locator.push "azure-functions-spec", path
13+
14+
options = create_test_options
15+
analyzer = Analyzer::Specification::AzureFunctions.new options
16+
analyzer.analyze
17+
ensure
18+
if dir
19+
File.delete(path) if path && File.exists?(path)
20+
Dir.delete(File.join(dir, function_dir)) if Dir.exists?(File.join(dir, function_dir))
21+
Dir.delete(dir) if Dir.exists?(dir)
22+
end
23+
end
24+
25+
private def tag_descriptions(endpoint : Endpoint, name : String) : Array(String)
26+
endpoint.tags.select { |t| t.name == name }.map(&.description)
27+
end
28+
29+
describe "Azure Functions Analyzer" do
30+
it "extracts httpTrigger methods + route" do
31+
endpoints = analyze_azure(<<-JSON, "Users")
32+
{
33+
"bindings": [
34+
{
35+
"type": "httpTrigger",
36+
"direction": "in",
37+
"name": "req",
38+
"methods": ["get", "post"],
39+
"route": "users/{id?}",
40+
"authLevel": "function"
41+
}
42+
]
43+
}
44+
JSON
45+
46+
endpoints.map { |e| {e.url, e.method} }.sort!.should eq([
47+
{"/users/{id?}", "GET"},
48+
{"/users/{id?}", "POST"},
49+
])
50+
endpoints.each { |e| tag_descriptions(e, "azure-auth-level").should eq ["function"] }
51+
end
52+
53+
it "falls back to function folder name when route is absent" do
54+
endpoints = analyze_azure(<<-JSON, "Healthcheck")
55+
{
56+
"bindings": [
57+
{"type": "httpTrigger", "methods": ["get"]}
58+
]
59+
}
60+
JSON
61+
62+
endpoints.size.should eq 1
63+
endpoints[0].url.should eq "/Healthcheck"
64+
endpoints[0].method.should eq "GET"
65+
end
66+
67+
it "defaults method to ANY when methods are not declared" do
68+
endpoints = analyze_azure(<<-JSON, "AnyFunc")
69+
{
70+
"bindings": [
71+
{"type": "httpTrigger", "route": "any"}
72+
]
73+
}
74+
JSON
75+
76+
endpoints.size.should eq 1
77+
endpoints[0].method.should eq "ANY"
78+
end
79+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
require "../../../spec_helper"
2+
require "../../../../src/detector/detectors/specification/*"
3+
require "../../../../src/models/code_locator"
4+
5+
describe "Detect Azure Functions function.json" do
6+
options = create_test_options
7+
instance = Detector::Specification::AzureFunctions.new options
8+
9+
it "detects function.json with httpTrigger binding" do
10+
src = %({"bindings":[{"type":"httpTrigger","direction":"in","methods":["get"]}]})
11+
locator = CodeLocator.instance
12+
locator.clear "azure-functions-spec"
13+
14+
instance.detect("MyFunc/function.json", src).should be_true
15+
locator.all("azure-functions-spec").should eq ["MyFunc/function.json"]
16+
end
17+
18+
it "rejects function.json without httpTrigger" do
19+
src = %({"bindings":[{"type":"queueTrigger"}]})
20+
instance.detect("Worker/function.json", src).should be_false
21+
end
22+
23+
it "ignores unrelated filenames" do
24+
instance.detect("config.json", %({"bindings":[{"type":"httpTrigger"}]})).should be_false
25+
end
26+
end

src/analyzer/analyzer.cr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ def initialize_analyzers(logger : NoirLogger)
101101
{"apisix", Specification::Apisix},
102102
{"aws_cdk", Specification::AwsCdk},
103103
{"aws_cloudformation", Specification::AwsCloudformation},
104+
{"azure_functions", Specification::AzureFunctions},
104105
{"cloudflare_wrangler", Specification::CloudflareWrangler},
105106
{"kong", Specification::Kong},
106107
{"oas2", Specification::Oas2},
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
require "../../../models/analyzer"
2+
3+
module Analyzer::Specification
4+
class AzureFunctions < Analyzer
5+
METHOD_ANY = "ANY"
6+
7+
def analyze
8+
spec_files = CodeLocator.instance.all("azure-functions-spec")
9+
return @result unless spec_files.is_a?(Array(String))
10+
11+
spec_files.each do |path|
12+
next unless File.exists?(path)
13+
14+
details = Details.new(PathInfo.new(path))
15+
content = read_file_content(path)
16+
begin
17+
process_doc(JSON.parse(content), path, details)
18+
rescue e
19+
@logger.debug "Exception processing #{path}"
20+
@logger.debug_sub e
21+
end
22+
end
23+
24+
@result
25+
end
26+
27+
private def process_doc(doc : JSON::Any, path : String, details : Details)
28+
bindings = doc["bindings"]?.try(&.as_a?)
29+
return unless bindings
30+
31+
function_name = File.basename(File.dirname(path))
32+
33+
bindings.each do |binding|
34+
binding_h = binding.as_h?
35+
next unless binding_h
36+
37+
type = binding_h["type"]?.try(&.as_s?) || ""
38+
next unless type == "httpTrigger"
39+
40+
methods = extract_methods(binding_h["methods"]?)
41+
methods = [METHOD_ANY] if methods.empty?
42+
43+
route = binding_h["route"]?.try(&.as_s?) || function_name
44+
normalized_path = route.starts_with?('/') ? route : "/#{route}"
45+
46+
auth_level = binding_h["authLevel"]?.try(&.as_s?)
47+
48+
methods.each do |method|
49+
endpoint = Endpoint.new(normalized_path, method, details)
50+
endpoint.add_tag(Tag.new("azure-function-name", function_name, "azure_functions_analyzer"))
51+
endpoint.add_tag(Tag.new("azure-auth-level", auth_level, "azure_functions_analyzer")) if auth_level && !auth_level.empty?
52+
@result << endpoint
53+
end
54+
end
55+
end
56+
57+
private def extract_methods(node : JSON::Any?) : Array(String)
58+
return [] of String if node.nil?
59+
arr = node.as_a?
60+
return [] of String unless arr
61+
arr.compact_map(&.as_s?).reject(&.empty?).map(&.upcase)
62+
end
63+
end
64+
end

0 commit comments

Comments
 (0)