Skip to content

Commit 2bf406d

Browse files
authored
feat(apigw_manager/plugin): 新增4个插件的 build 函数 (TencentBlueKing#222)
1 parent 100b22d commit 2bf406d

3 files changed

Lines changed: 750 additions & 0 deletions

File tree

sdks/apigw-manager/src/apigw_manager/plugin/config.py

Lines changed: 355 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
# an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the
88
# specific language governing permissions and limitations under the License.
99

10+
import ast
11+
import json
12+
import jsonschema
1013
import ipaddress
14+
from dataclasses import dataclass
1115
from typing import Dict, List, Optional, Tuple
1216

17+
18+
from .constants import Draft7Schema
1319
from .utils import literal_unicode, yaml_dump, yaml_text_indent
1420

1521

22+
VARS_ALLOWED_COMPARISON_SYMBOLS = {"==", "~=", ">", ">=", "<", "<=", "~~", "~*", "in", "has", "!", "ipmatch"}
23+
24+
1625
def build_bk_header_rewrite(set: Dict[str, str], remove: List[str]) -> Dict[str, str]:
1726
"""generate bk-header-rewrite plugin config
1827
@@ -265,3 +274,349 @@ def build_stage_plugin_config_for_definition_yaml(
265274
}
266275
)
267276
return indented_plugin_configs
277+
278+
279+
def build_bk_mock(
280+
response_example: str,
281+
response_headers: Dict[str, str],
282+
response_status: int = 200,
283+
) -> Dict[str, str]:
284+
"""generate bk-mock plugin config
285+
286+
Args:
287+
response_example (str): 响应体。
288+
response_headers (Dict[str, str]): 响应头。
289+
response_status (int, optional): 响应状态码。Defaults to 200.
290+
291+
Raises:
292+
ValueError: response_status cannot be less than 100
293+
ValueError: key {} can not contain ':'
294+
295+
Returns:
296+
{
297+
"type": "bk-mock",
298+
"yaml": "response_headers:\n- key: key1\nvalue: value1\n- key: key2\nvalue: value2\nresponse_status: 200\nresponse_example: ''"
299+
}
300+
"""
301+
if response_status < 100:
302+
raise ValueError("response_status cannot be less than 100")
303+
304+
response_header_data = []
305+
for k, v in response_headers.items():
306+
if ":" in k:
307+
raise ValueError(f"key {k} can not contain ':'")
308+
response_header_data.append({"key": k, "value": v})
309+
310+
return {
311+
"type": "bk-mock",
312+
"yaml": yaml_dump(
313+
{
314+
"response_example": response_example,
315+
"response_headers": response_header_data,
316+
"response_status": response_status,
317+
}
318+
),
319+
}
320+
321+
322+
@dataclass
323+
class UnhealthyConfig:
324+
http_statuses: List[int] = None
325+
failures: int = 3
326+
327+
328+
@dataclass
329+
class HealthyConfig:
330+
http_statuses: List[int] = None
331+
successes: int = 3
332+
333+
334+
def build_api_breaker(
335+
break_response_body: str,
336+
break_response_headers: Dict[str, str],
337+
unhealthy: UnhealthyConfig,
338+
healthy: HealthyConfig,
339+
break_response_code: int = 502,
340+
max_breaker_sec: int = 300,
341+
) -> Dict[str, str]:
342+
"""generate api-breaker plugin config
343+
344+
Args:
345+
break_response_body (str): 当上游服务处于不健康状态时返回的 HTTP 响应体信息。
346+
break_response_headers (Dict[str, str]): 当上游服务处于不健康状态时返回的 HTTP 响应头信息。
347+
unhealthy (UnhealthyConfig): 当上游服务处于不健康状态时的 HTTP 的状态码和异常请求次数。
348+
- 应包含键 "http_statuses"(不健康状态码列表)和"failures"(触发不健康状态的异常请求次数)
349+
healthy (HealthyConfig): 上游服务处于健康状态时的 HTTP 状态码和连续正常请求次数。
350+
- 应包含键 "http_statuses"(健康状态码列表)和"successes"(触发健康状态的连续正常请求次数)
351+
break_response_code (int, optional): 当上游服务处于不健康状态时返回的 HTTP 错误码。Defaults to 502.
352+
max_breaker_sec (int, optional): 上游服务熔断的最大持续时间,以秒为单位,最小 3 秒。Defaults to 300.
353+
354+
Raises:
355+
ValueError: max_breaker_sec duration cannot be less than 3 seconds
356+
ValueError: unhealthy status must be between 500 and 599
357+
ValueError: unhealthy failures must be greater than or equal to 1
358+
ValueError: healthy status must be between 200 and 499
359+
ValueError: healthy successes must be greater than or equal to 1
360+
ValueError: key {} can not contain ':'
361+
362+
Returns:
363+
{
364+
"type": "api-breaker",
365+
"yaml": "break_response_code: 502\nbreak_response_body: ''\nbreak_response_headers:\n - key: key1\n value: value1\nmax_breaker_sec: 300\nunhealthy:\n http_statuses:\n - 503\n failures: 3\nhealthy:\n http_statuses:\n - 200\n successes: 3"
366+
}
367+
"""
368+
369+
if break_response_code is None:
370+
raise ValueError("break_response_code is required")
371+
372+
if not (200 <= break_response_code <= 599):
373+
raise ValueError("break_response_code must be between 200 and 599")
374+
375+
if max_breaker_sec and max_breaker_sec < 3:
376+
raise ValueError("max_breaker_sec duration cannot be less than 3 seconds")
377+
378+
unhealthy_http_statuses = unhealthy.http_statuses or [500]
379+
for status in unhealthy_http_statuses:
380+
if not (500 <= status <= 599):
381+
raise ValueError("unhealthy status must be between 500 and 599")
382+
383+
unhealthy_failures = unhealthy.failures
384+
if unhealthy_failures is not None and unhealthy_failures < 1:
385+
raise ValueError("unhealthy failures must be greater than or equal to 1")
386+
387+
healthy_http_statuses = healthy.http_statuses or [200]
388+
for status in healthy_http_statuses:
389+
if not (200 <= status <= 499):
390+
raise ValueError("healthy status must be between 200 and 499")
391+
392+
healthy_successes = healthy.successes
393+
if healthy_successes is not None and healthy_successes < 1:
394+
raise ValueError("healthy successes must be greater than or equal to 1")
395+
396+
break_response_header_data = []
397+
for k, v in break_response_headers.items():
398+
if ":" in k:
399+
raise ValueError(f"key {k} can not contain ':'")
400+
break_response_header_data.append({"key": k, "value": v})
401+
402+
return {
403+
"type": "api-breaker",
404+
"yaml": yaml_dump(
405+
{
406+
"break_response_code": break_response_code,
407+
"break_response_body": break_response_body,
408+
"break_response_headers": break_response_header_data,
409+
"max_breaker_sec": max_breaker_sec,
410+
"unhealthy": {
411+
"http_statuses": unhealthy_http_statuses,
412+
"failures": unhealthy_failures
413+
},
414+
"healthy": {
415+
"http_statuses": healthy_http_statuses,
416+
"successes": healthy_successes
417+
},
418+
}
419+
),
420+
}
421+
422+
423+
@dataclass
424+
class AbortConfig:
425+
http_status: int
426+
body: str
427+
percentage: int
428+
vars: str
429+
430+
431+
@dataclass
432+
class DelayConfig:
433+
duration: float
434+
percentage: int
435+
vars: str
436+
437+
438+
def build_fault_injection(
439+
abort: Optional[AbortConfig] = None,
440+
delay: Optional[DelayConfig] = None,
441+
) -> Dict[str, str]:
442+
"""generate fault-injection plugin config
443+
444+
Args:
445+
abort (AbortConfig, optional): 中断状态。
446+
- http_status (int): 返回给客户端的 HTTP 状态码。
447+
- body (str): 返回给客户端的响应数据。支持使用 NGINX 变量,如 client addr: $remote_addr\n
448+
- percentage (int): 将被中断的请求占比(0-100)。
449+
- vars (str): 执行故障注入的规则,当规则匹配通过后才会执行故障注。vars 是一个表达式的列表,来自 lua-resty-expr。
450+
delay (DelayConfig, optional): 延迟状态。
451+
- duration (number): 延迟时间,单位秒,只能填入整数。
452+
- percentage (int): 将被延迟的请求占比(0-100)
453+
- vars (str): 执行请求延迟的规则,当规则匹配通过后才会延迟请求。vars 是一个表达式列表,来自 lua-resty-expr。
454+
455+
Raises:
456+
ValueError: At least one of the conditions 'abort' or 'delay' must be configured
457+
ValueError: http_status is required in abort
458+
ValueError: http_status must be greater than 200
459+
ValueError: The percentage of abort must be greater than 0 and less than or equal to 100
460+
ValueError: duration is required in delay
461+
462+
Returns:
463+
{
464+
"type": "fault-injection",
465+
"yaml": "abort:\n body: ''\n http_status: 500\n percentage: 20\n vars: ''\ndelay:\n duration: 2.5\n percentage: 100\n vars: ''\n"
466+
}
467+
"""
468+
config = {}
469+
470+
if not abort and not delay:
471+
raise ValueError("At least one of the conditions 'abort' or 'delay' must be configured")
472+
473+
if abort:
474+
http_status = abort.http_status
475+
if not http_status:
476+
raise ValueError("http_status is required in abort")
477+
if http_status < 200:
478+
raise ValueError("http_status must be greater than 200")
479+
480+
percentage = abort.percentage
481+
_check_percentage(percentage, "abort")
482+
483+
abort_vars = abort.vars
484+
if abort_vars:
485+
_check_vars(abort_vars, "abort")
486+
487+
config["abort"] = {
488+
"http_status": http_status,
489+
"body": abort.body,
490+
"percentage": percentage,
491+
"vars": abort_vars
492+
}
493+
494+
if delay:
495+
if not delay.duration:
496+
raise ValueError("duration is required in delay")
497+
498+
percentage = delay.percentage
499+
_check_percentage(percentage, "delay")
500+
501+
delay_vars = delay.vars
502+
if delay_vars:
503+
_check_vars(delay_vars, "delay")
504+
505+
config["delay"] = {
506+
"duration": delay.duration,
507+
"percentage": percentage,
508+
"vars": delay_vars
509+
}
510+
511+
return {
512+
"type": "fault-injection",
513+
"yaml": yaml_dump(config)
514+
}
515+
516+
517+
def build_request_validation(
518+
body_schema: str,
519+
header_schema: str,
520+
rejected_msg: str,
521+
rejected_code: int = 400,
522+
) -> Dict[str, str]:
523+
"""generate request-validation plugin config
524+
525+
Args:
526+
body_schema (str): request body 数据的 JSON Schema。
527+
header_schema (str): request header 数据的 JSON Schema。
528+
rejected_msg (str): 拒绝信息。
529+
rejected_code (int, optional): 拒绝状态码。Defaults to 400.
530+
531+
Raises:
532+
ValueError: rejected_code must be between 200 and 599
533+
ValueError: header_schema or body_schema should be configured at least one
534+
ValueError: Your {schema_name} Schema is not a valid JSON
535+
ValueError: Your {schema_name} Schema is not valid: {err}
536+
537+
Returns:
538+
{
539+
"type": "request-validation",
540+
"yaml": 'body_schema: \'{"type": "object"}\'\nheader_schema: \'{"type": "object"}\'\nrejected_code: 403\nrejected_msg: test\n'
541+
}
542+
"""
543+
config = {
544+
"rejected_code": rejected_code,
545+
"rejected_msg": rejected_msg
546+
}
547+
548+
if not (200 <= rejected_code <= 599):
549+
raise ValueError("rejected_code must be between 200 and 599")
550+
551+
if not body_schema and not header_schema:
552+
raise ValueError("header_schema or body_schema should be configured at least one")
553+
554+
if body_schema:
555+
_validate_json_schema("body_schema", body_schema)
556+
config["body_schema"] = body_schema
557+
558+
if header_schema:
559+
_validate_json_schema("header_schema", header_schema)
560+
config["header_schema"] = header_schema
561+
562+
return {
563+
"type": "request-validation",
564+
"yaml": yaml_dump(config)
565+
}
566+
567+
568+
def _check_percentage(percentage: int, location: str):
569+
if percentage and not (0 < percentage <= 100):
570+
raise ValueError(f"The percentage of {location} must be greater than 0 and less than or equal to 100")
571+
572+
573+
def _check_vars(vars: str, location: str):
574+
"""check vars of lua-resty-expr
575+
vars = `[
576+
[
577+
[ "arg_name","==","jack" ],
578+
[ "arg_age","==",18 ]
579+
],
580+
[
581+
[ "arg_name2","==111","allen" ]
582+
]
583+
]`
584+
585+
"""
586+
try:
587+
parsed_vars = ast.literal_eval(vars)
588+
except Exception as e:
589+
raise ValueError(f"The vars of {location} is not valid, error: {e}")
590+
591+
# 第一层 parsed_vars = [ [a], [] ]
592+
if not isinstance(parsed_vars, list):
593+
raise TypeError(f"The vars of {location} should be list")
594+
595+
for index, v in enumerate(parsed_vars):
596+
# 中间层 v = [a]
597+
if not isinstance(v, list):
598+
raise TypeError(f"The vars of {location} at index {index} should be list")
599+
600+
for i, item in enumerate(v):
601+
# 最内侧 a = [ "arg_name","==","jack" ]
602+
if isinstance(item, list):
603+
if len(item) != 3:
604+
raise ValueError(f"The vars of {location} at index [{index}][{i}] should have 3 elements")
605+
if item[1] not in VARS_ALLOWED_COMPARISON_SYMBOLS:
606+
raise ValueError(
607+
f"The vars of {location} at index [{index}][{i}] should have a valid comparison symbol"
608+
)
609+
else:
610+
raise TypeError(f"The vars of {location} at index [{index}][{i}] should be list")
611+
612+
613+
def _validate_json_schema(schema_name: str, json_schema: str):
614+
try:
615+
data = json.loads(json_schema)
616+
except json.JSONDecodeError:
617+
raise ValueError(f"Your {schema_name} Schema is not a valid JSON")
618+
619+
try:
620+
jsonschema.validate(instance=data, schema=Draft7Schema)
621+
except jsonschema.exceptions.ValidationError as err:
622+
raise ValueError(f"Your {schema_name} Schema is not valid: {err}")

0 commit comments

Comments
 (0)