Un Attribut [APIEndpoint()] pour les API PowerShell Universal
Je travaille toujours à rendre l’automatisation efficace, et j’ai récemment créé un nouveau module qui simplifie la création d’API. Voici synedgy.universal.helper, conçu pour faciliter la construction d’API PowerShell Universal.
Pas besoin d’avoir un niveau PowerShell avancé pour l’utiliser !
Ce module expose l’accélérateur de type [APIEndpoint()], en le principe du module PowerShell exportant ses classes (comme expliqué dans mon précédent article). Grâce à cet attribut, je peux définir mon endpoint d’API directement là où je définis sa fonction, a l’intérieur du module.
API PowerShell Universal
PowerShell Universal est idéal pour construire vos API REST directement en PowerShell et générer automatiquement la documentation Swagger (OAS). Pour cela, vous pouvez créer chaque endpoint avec la commande New-PSUApiEndpoint.

La capture d’écran ci-dessus montre une API PowerShell Universal (consultez nos cours) écrite en PowerShell, ainsi que le résultat dans l’interface d’administration.
Méthodes documentées
Vous avez principalement deux façons de l’utiliser :
- Spécifier le
ScriptBlockqui exécutera la commande (ancienne méthode). - Spécifier le module et la commande à exécuter lorsque l’endpoint est appelé (plus récent, depuis la v5 il me semble).
Dans les deux cas, vous pouvez définir d’autres informations : l’URL, la méthode HTTP, etc. Consultez l’aide de New-PSUApiEndpoint pour plus de détails.
Conseils pour la production
Beaucoup de débutants utilisent New-PSUApiEndpoint avec le paramètre -ScriptBlock. C’est pratique pour tester, mais ça n’évolue pas bien : votre fichier endpoints.ps1 devient vite ingérable !
Mieux vaut garder vos endpoints définis dans des modules PowerShell, au même endroit que votre logique métier.
Autre conseil : exécuter vos API PowerShell Universal dans des Environnements dédiés améliore nettement les performances et la gestion.
Les environnements dans PowerShell Universal
Un environnement permet de précharger des modules et d’exécuter un script d’initialisation pour que chaque thread utilisé par les appels REST ait déjà tout en mémoire.
Résultat : réponses plus rapides, redémarrage facile en cas de problème, isolation entre APIs et moins de conflits.
$newPSUEnvironmentParams = @{
Name = 'PSUModuleEnv'
Variables = @('*')
Description = 'Environment for PSUModule API'
Type = 'PowerShell7'
Path = 'pwsh'
Arguments = '-NoLogo'
Modules = @('PSUModule')
DisableImplicitWinCompat = $true
PSModulePath = (Split-Path -Path $PSScriptRoot -Parent)
PersistentRunspace = $true
}
New-PSUEnvironment @newPSUEnvironmentParamsCombiné avec PowerShell Universal 5 et le déploiement via modules PowerShell, cela rend la gestion des ressources plus simple et l’architecture plus modulaire.
Endpoints définis dans un module
Je suis convaincu que les endpoints doivent être des fonctions PowerShell définies dans des modules.
Mais je ne suis pas sûr que l’option -Module et -Command soit toujours idéale :
- Les noms de paramètres PowerShell sont en PascalCase, mais en REST on utilise souvent camelCase.
- La définition de l’endpoint est séparée de la fonction, donc il faut maintenir deux endroits différents.
- Si on déplace des endpoints vers un autre environnement, il faut tout modifier manuellement.
Si je défini mon paramètre de fonction comme ceci :
[Parameter()]
[string]
$MyStringLe Swagger montrera qu’il attends MyString, mais la convention (qui s’aligne avec la nomenclature des variables JS) voudrait qu’il soit défini comme ceci :
myStringPas une énorme différence, mais cela peut compliquer l’intégration avec d’autres services écrits en Java qui sont TRÈS stricts sur la casse…
Autre point qui ne me plaisait pas : lorsque j’écris une fonction, je dois définir les informations de son endpoint correspondant ailleurs…
Non seulement je crée une fonction Get-Something, mais je dois aussi ajouter une entrée dans un fichier .universal/endpoints.ps1 de mon module… Et chaque fois que j’ajoute une fonction, je dois m’assurer de définir également son endpoint correspondant.
Enfin, il arrive que l’on définisse des endpoints, mais que, pour des raisons de diagnostic, on veuille tous les déplacer dans un autre environnement… Dans ce cas, il faut faire une recherche et remplacement dans ce fichier endpoints.ps1.
Fastidieux et risqué.
Mais il existe une meilleure solution.
L’attribut [APIEndpoint()]
En créant un attribut PowerShell [APIEndpoint()], on peut stocker toutes les infos nécessaires à l’endpoint directement dans la fonction, juste après [CmdletBinding()].
Voici un exemple :
function Get-Something
{
[CmdletBinding()]
[ApiEndpoint(
Path = "/get-something",
Method = ('GET'),
Description = "Get some string based on the someThing string parameter.",
Environment = "Agent",
ContentType = 'text/event-stream; charset=utf-8'
)]
param
(
[Parameter()]
[string]
$SomeThing
)
$Something
#...
}Mais comment utiliser ces metadata!?
Utiliser Import-PSUEndpoint
Le module synedgy.universal.helper fournit la fonction Import-PSUEndpoint qui :
- Cherche toutes les fonctions publiques d’un module décorées avec
[APIEndpoint()]. - Génère automatiquement les ScriptBlocks avec paramètres en camelCase.
- Relie le tout à la fonction originale via
$PSBoundParameters.
Par exemple, dans le fichier .universal/endpoints.ps1 :
# Assuming synedgy.universal.helper is in one of the path in $Env:PSModulePath
# Most likely in $repository/Modules
Import-PSUEndpoint -Module PSUModule -Environment PSUModuleEnvCette fonction examine toutes les fonctions exportées (publiques) de PSUModule qui possèdent l’attribut [APIEndpoint()] défini et dont IsEndpoint est a $true.
Pour chacune de ces fonctions endpoint détectées, elle récupère le bloc param afin de générer un [ScriptBlock] avec la même signature, mais dont les paramètres sont écrits en camelCase, et dont le corps se contente de splat les $PSBoundParameters vers la fonction du module.
En supposant que ma fonction Get-Something soit écrite comme ceci :
function Get-Something
{
[CmdletBinding()]
param
(
[Parameter()]
[string]
$SomeThing
)
$Something
}J’ajoute l’attribut [APIEndpoint()] juste sous [CmdletBinding()] :
[ApiEndpoint(
Path = "/getSomething",
Method = ('GET'),
Description = "Get some string based on the someThing string parameter.",
Environment = "PowerShell 7", # Available by default in PowerShell Universal 5
ContentType = 'text/event-stream; charset=utf-8'
)]Avec mon fichier d’endpoint configuré ainsi :
# .universal/endpoints.ps1
Import-PSUEndpoint -Module PSUModule -Environment PSUModuleEnvLe ScriptBlock généré pour mon endpoint ressemble maintenant à ceci (notez la casse des paramètres) :
{
[CmdletBinding()]
param
(
[Parameter()]
[string]
$someThing
)
Get-Something @PSBoundParameters
}Je pense que c’est une façon plus évolutive de définir mon API PowerShell Universal, où le code et la définition de l’API se trouvent au même endroit.
Fonctionnement interne
Pour ce faire, je défini un attribut personnalisé :
- Créer une classe dérivée de
System.Attribute. - Ajouter toutes les propriétés nécessaires (Path, Method, Environment, etc.).
- Exporte un TypeAccelerator pour l’utiliser en dehors du module.
Voici le code de l’Attribut [APIEndpoint()].
class APIEndpoint : System.Attribute
{
[bool]$IsEndpoint = $true # Indicates that this is an API endpoint attribute
[string]$Name
[string]$version = 'v1' # Version of the API endpoint
[string]$Path # aka the URL path of the endpoint
[string]$Description # Description of the endpoint
[ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD')]
[string[]]$Method # HTTP method (GET, POST, PUT, DELETE, etc.)
[bool]$Authentication = $false # Whether the endpoint requires authentication
[string[]]$Role # Role required to access the endpoint
[string]$Tag # Tag for categorizing the endpoint
[int]$Timeout# Timeout for the endpoint in seconds
[string]$Environment # Environment where the endpoint is executed (e.g. the PowerShell Universal environment)
[string]$ContentType = 'application/json; charset=utf-8' # Content type of the response
[ValidateSet('Information', 'Warning', 'Error', 'Verbose', 'Debug')]
[string[]]$LogLevel = @('Information') # Log level for the endpoint
[scriptblock]$Parameters # Override of parameters for the endpoint to splat to the command
APIEndpoint ()
{
# Default constructor for the attribute
}
}Avec cet attribut créé, je peut l’ajouter a mes fonctions, mais s’il est créé dans le module, il se sera disponible qu’ici (j’explique un peu plus tard).
Pour retrouver les fonctions d’un module qui définissent des endpoints via un attribut on peut utiliser la ligne suivante.
Get-Command -Module PSUModule | Where-Object -FilterScript {
$_.ScriptBlock.Attributes.Where{
$_.TypeId.ToString() -eq 'APIEndpoint' -and $_.IsEndpoint -eq $true
}
}Et de ces fonctions on peut extraire l’Attribut et ses propriétés.
Si cet attribut était seulement défini dans mon module PowerShell Universal, il pourrait être utilisé seulement dans ce module, et je devrait le redéfinir a chaque fois que j’en ai besoin pour créer une API.
Pour pouvoir le réutiliser, je l’ai défini dans mon module synedgy.universal.helper et il vous suffit de l’ajouter dans les RequiredModules du module manifest (le psd1) du module que vous éditer.
Pour qu’il soit exporté des que le module est importé j’utilise l’astuce partagée précédemment: Module PowerShell exportant ses Classes.
Évolutions futures (travail en cours)
Ce n’est que le début, mais j’ai déjà en tête quelques améliorations à apporter à la définition d’API PowerShell Universal…
Si vous avez des idées, n’hésitez pas à ouvrir une issue dans le dépôt GitHub.
Charger le module dans un thread
Le dernier défi que je rencontre avec cette méthode, c’est que, lorsque la configuration du serveur PowerShell Universal est chargée, le module doit être importé dans le thread principal de PowerShell Universal afin de pouvoir générer les endpoints.
Le problème, c’est que lorsque le module intègre et charge une DLL, PowerShell verrouille le fichier. Comme nous le faisons avec un ScriptsToProcess dans notre module synedgy.PSSqlite, un handle est créé sur le module lors de l’import.
Cela pose problème si l’on souhaite supprimer ou déplacer cette DLL — par exemple lors d’un New-PSUDeployment, où ce module devrait être remplacé par une version plus récente.
Si le module ne sert qu’à traiter des requêtes dans un environnement personnalisé, il n’a alors aucune raison d’être chargé sur le thread principal avec le handle encore actif.
Nous pouvons espérer que PowerShell Universal libère tous les environnements lors de la mise à jour vers un nouveau déploiement, ce qui libérerait le handle sur la DLL (bientôt, m’a dit Adam).
Pour cela, il faudrait charger le module dans un nouveau thread, générer les ScriptBlocks, les fournir à PowerShell Universal pour construire notre API, puis fermer le thread, ce qui libérerait le handle sur la DLL.
Remplacer les paramètres [switch] par [bool]
Je dois encore vérifier si c’est toujours un problème, mais je me souviens avoir rencontré cette difficulté avec PowerShell Universal v4 : les paramètres [switch] ne se traduisaient pas correctement dans les endpoints API.
Si c’est toujours le cas, il serait trivial de remplacer le type [switch] par [bool] dans le ScriptBlock généré définissant l’endpoint, en utilisant l’AST (puisque nous modifions déjà les noms des variables en camelCase).
Comme nous faisons un splat de $PSBoundParameters vers la commande du module, cela n’aurait aucun impact sur la gestion des valeurs.
Discover more from SynEdgy
Subscribe to get the latest posts sent to your email.
Super approche, est-ce que cela gère des function qui ont plusieurs parameterset ?
En l’état, ni plus ni moins que ce que PowerShell Universal supporte (car c’est la même signature d’endpoint que la fonction).
Par contre, il est possible de définir plusieurs [APIEndpoint()] attribute pour une même fonction, ce qui permet d’avoir différentes propriétés d’attribut (et donc d’endpoint) en fonction de la méthode par exemple (GET, SET).
On pourrait ajouter une propriété `ParameterSetName` a l’attribut [APIEndpoint()] qui génèrerai la signature uniquement pour ce `ParameterSetName` quand il est renseigné.
Ou alors limiter les arguments disponibles avec `ArgumentsToInclude` et `ArgumentsToExclude` comme propriété de l’attribut.
Une bonne idée pour ouvrir une discussion sur le repo: https://github.com/SynEdgy/synedgy.universal.helper/issues
🙂